diff --git a/simpler_setup/tools/README.md b/simpler_setup/tools/README.md index 9a2aa8699..5deeab6c0 100644 --- a/simpler_setup/tools/README.md +++ b/simpler_setup/tools/README.md @@ -303,16 +303,16 @@ python -m simpler_setup.tools.deps_viewer outputs/_/deps.json \ python -m simpler_setup.tools.deps_viewer outputs/_/deps.json \ --func-names outputs/_/name_map_TestPA_basic.json -# Transitive reduction: drop edges implied by a longer path, print what was removed +# Transitive reduction: select non-redundant edges, print what was removed python -m simpler_setup.tools.deps_viewer outputs/_/deps.json \ --edge-mode reduced -# Redundant-only: draw ONLY the transitively-implied edges reduced would drop +# Redundant-only: select the transitively-implied edges reduced would drop python -m simpler_setup.tools.deps_viewer outputs/_/deps.json \ --edge-mode omitted ``` -`--edge-mode` selects which structural `(pred, succ)` edges are drawn: +`--edge-mode` selects which structural `(pred, succ)` edges are visible: - `full` (default) — every dependency edge. - `reduced` — the minimal (transitively-reduced) edge set: every edge already @@ -323,21 +323,24 @@ python -m simpler_setup.tools.deps_viewer outputs/_/deps.json \ `reduced` and `omitted` print the redundant edges to stdout as a ` -> ` list, where each task uses the same label as the rendered graph — the bare `local` counter when every task is in ring 0, or the explicit -`(ring, local)` tuple once any task lives in ring >= 1. Both `text` and `html` -output honor the mode. When `-o` is omitted the graph is written to a -mode-specific stem (`deps_viewer_reduced.*` / `deps_viewer_omitted.*`) rather -than `deps_viewer.*` so it never clobbers a full-graph render in the same -directory. Reduction is purely structural (it ignores the per-edge tensor/arg -identity) and is skipped with a warning if the graph contains a cycle. +`(ring, local)` tuple once any task lives in ring >= 1. Text output emits only +the selected edge set. HTML output keeps every edge in the Graphviz layout and +colors unselected edges like the page background, so `reduced` / `omitted` +preserve the full-graph node placement and routing while showing only the +selected edge set. When `-o` is omitted the graph is written to a mode-specific +stem (`deps_viewer_reduced.*` / `deps_viewer_omitted.*`) rather than +`deps_viewer.*` so it never clobbers a full-graph render in the same directory. +Reduction is purely structural (it ignores the per-edge tensor/arg identity) +and is skipped with a warning if the graph contains a cycle. ### Command-Line Options | Option | Short | Description | | ------ | ----- | ----------- | | `input` | | Path to `deps.json` (default: newest under `./outputs/`) | -| `--output` | `-o` | Output path (default: `deps_viewer.txt` for text, `deps_viewer.html` for HTML; `reduced`/`omitted` use the `deps_viewer_reduced.*` / `deps_viewer_omitted.*` stem) | +| `--output` | `-o` | Output path; default stem is `deps_viewer`, or `deps_viewer_{mode}` for `reduced` / `omitted` | | `--format` | | Output format: `text` (default) or `html` | -| `--edge-mode` | | `full` (default) draws every edge; `reduced` draws the transitively-reduced (minimal) set; `omitted` draws only the redundant edges reduced would drop. `reduced`/`omitted` print the redundant edges and apply to both formats. | +| `--edge-mode` | | Select visible edges: `full`, `reduced`, or `omitted`; HTML preserves full layout. | | `--engine` | | HTML-only Graphviz layout engine: `dot` (default), `sfdp`, `neato`, `fdp`, `circo`, `twopi` | | `--direction` | | HTML-only flow direction for hierarchical layouts: `LR` (default) / `TB` / `BT` / `RL` | | `--show-tensor-info` | | HTML-only: render per-task tensor rows and route edges to specific arg ports | diff --git a/simpler_setup/tools/deps_viewer.py b/simpler_setup/tools/deps_viewer.py index a57500e49..f4a736ee3 100644 --- a/simpler_setup/tools/deps_viewer.py +++ b/simpler_setup/tools/deps_viewer.py @@ -34,7 +34,7 @@ python -m simpler_setup.tools.deps_viewer DEPS_JSON --edge-mode reduced python -m simpler_setup.tools.deps_viewer DEPS_JSON --edge-mode omitted -``--edge-mode`` selects which edges are drawn (structural transitive reduction, +``--edge-mode`` selects which edges are visible (structural transitive reduction, purely on ``(pred, succ)`` — per-edge tensor identity is ignored, and it is skipped with a warning if the graph contains a cycle): @@ -44,8 +44,10 @@ - ``omitted`` — only the redundant edges ``reduced`` would drop (its complement), useful for auditing exactly which dependencies are transitively covered. -``reduced`` and ``omitted`` print the redundant edges to stdout and emit in -whichever format is selected. +``reduced`` and ``omitted`` print the redundant edges to stdout. Text output +emits only the selected edge set. HTML output keeps every edge in the Graphviz +layout and colors the unselected edges like the page background, preserving the +full-graph layout while making the selected edge set visible. HTML output requires Graphviz installed (``brew install graphviz`` / ``apt install graphviz``). Text output does not. @@ -733,6 +735,7 @@ def emit_dot( tensor_table=None, task_table=None, show_tensor_info=None, + hidden_edges=None, ): """Graphviz DOT source. Used internally to feed the layout engine before wrapping the SVG in HTML. @@ -752,6 +755,7 @@ def emit_dot( annotations = annotations or {} tensor_table = tensor_table or {} task_table = task_table or {} + hidden_edges = set(hidden_edges or ()) show_tensor = bool(task_table) if show_tensor_info is None else bool(show_tensor_info and task_table) lines = [ "digraph deps {", @@ -767,13 +771,22 @@ def emit_dot( lines.append(f" {_node_id(n)} [shape=none, margin=0, label=<{html}>];") continue lines.append(f" {_node_id(n)} [{_plain_node_attrs(n, meta, task_table, fmt_task)}];") + + def edge_attr_str(edge_attrs): + return (" [" + ", ".join(edge_attrs) + "]") if edge_attrs else "" + + def hidden_edge_attrs(): + return ['color="#eef2f7"', 'fontcolor="#eef2f7"'] + for pred, succ in edges: + hidden = (pred, succ) in hidden_edges + hidden_attrs = hidden_edge_attrs() if hidden else [] if not show_tensor: - lines.append(f" {_node_id(pred)} -> {_node_id(succ)};") + lines.append(f" {_node_id(pred)} -> {_node_id(succ)}{edge_attr_str(hidden_attrs)};") continue rows = annotations.get((pred, succ), []) if not rows: - lines.append(f" {_node_id(pred)} -> {_node_id(succ)};") + lines.append(f" {_node_id(pred)} -> {_node_id(succ)}{edge_attr_str(hidden_attrs)};") continue for row in rows: arg = row.get("arg") @@ -782,19 +795,21 @@ def emit_dot( tail = "" head = "" edge_attrs = [] + if hidden: + edge_attrs.extend(hidden_edge_attrs()) arg_idx = _normalize_small_int(arg) if arg_idx is not None and arg_idx >= 0: head = f":in_{arg_idx}:w" out_port = _producer_output_port(task_table.get(pred), tid) if out_port: tail = f":{out_port}:e" - if source == "explicit": + if source == "explicit" and not hidden: edge_attrs.append('style="dashed"') edge_attrs.append('color="#B0B0B0"') overlap = row.get("overlap") - if overlap and overlap != "covered": + if overlap and overlap != "covered" and not hidden: edge_attrs.append(f'label="{_html_escape(overlap)}", fontsize=8, fontcolor="#C04040"') - attr_str = (" [" + ", ".join(edge_attrs) + "]") if edge_attrs else "" + attr_str = edge_attr_str(edge_attrs) lines.append(f" {_node_id(pred)}{tail} -> {_node_id(succ)}{head}{attr_str};") lines.append("}") return "\n".join(lines) + "\n" @@ -1027,8 +1042,14 @@ def emit_html( tensor_table=None, task_table=None, show_tensor_info=None, + html_edge_style=None, ): """Build the pan/zoom HTML page: DOT → Graphviz SVG → inline into template.""" + html_edge_style = html_edge_style or {} + hidden_edges = html_edge_style.get("hidden_edges") + visible_edge_count = html_edge_style.get("visible_edge_count") + if visible_edge_count is None: + visible_edge_count = len(edges) dot = emit_dot( edges, nodes, @@ -1038,6 +1059,7 @@ def emit_html( tensor_table=tensor_table, task_table=task_table, show_tensor_info=show_tensor_info, + hidden_edges=hidden_edges, ) svg_bytes = render_svg(dot, engine=engine) svg_text = svg_bytes.decode("utf-8", errors="replace") @@ -1045,7 +1067,7 @@ def emit_html( svg_text = svg_text[svg_text.index(" {fmt_task(v)}") - edges = shown - # Keep only the annotations of the shown edges so annotated-edge - # counts (emit_text summary, the final print) stay consistent with - # the rendered edge set instead of counting the hidden ones. - shown_set = set(shown) - annotations = {k: v for k, v in annotations.items() if k in shown_set} + if args.format == "html": + hidden_html_edges = set(removed if args.edge_mode == "reduced" else kept) + visible_html_edge_count = len(shown) + print( + f"HTML layout preserves all {len(edges)} edge(s); " + f"{len(hidden_html_edges)} unselected edge(s) are colored as background" + ) + else: + edges = shown + # Keep only the annotations of the shown edges so annotated-edge + # counts (emit_text summary, the final print) stay consistent with + # the rendered edge set instead of counting the hidden ones. + shown_set = set(shown) + annotations = {k: v for k, v in annotations.items() if k in shown_set} mode_stem = f"deps_viewer_{args.edge_mode}" out = ( @@ -1247,9 +1280,19 @@ def main(argv=None): tensor_table=tensor_table, task_table=task_table, show_tensor_info=args.show_tensor_info, + html_edge_style={ + "hidden_edges": hidden_html_edges, + "visible_edge_count": visible_html_edge_count, + }, ) out.write_text(html) - print(f"Wrote {out} ({len(nodes)} nodes, {len(edges)} edges, engine={args.engine}, format=html)") + if hidden_html_edges: + print( + f"Wrote {out} ({len(nodes)} nodes, {visible_html_edge_count} visible edges, " + f"{len(edges)} layout edges, engine={args.engine}, format=html)" + ) + else: + print(f"Wrote {out} ({len(nodes)} nodes, {len(edges)} edges, engine={args.engine}, format=html)") return 0 diff --git a/tests/ut/py/test_deps_viewer.py b/tests/ut/py/test_deps_viewer.py index 0cf0488f7..cfd10eeaa 100644 --- a/tests/ut/py/test_deps_viewer.py +++ b/tests/ut/py/test_deps_viewer.py @@ -243,6 +243,19 @@ def test_emit_dot_handles_missing_task_table(): assert 'label="1 · alloc"' in dot +def test_emit_dot_hides_selected_edges_with_background_color(): + dot = deps_viewer.emit_dot( + edges=[(1, 2), (2, 3)], + nodes=[1, 2, 3], + meta={}, + task_table=None, + hidden_edges={(1, 2)}, + ) + + assert 'T0_1 -> T0_2 [color="#eef2f7", fontcolor="#eef2f7"];' in dot + assert "T0_2 -> T0_3;" in dot + + def test_emit_html_default_preserves_task_table_rendering(monkeypatch): captured = {} @@ -285,17 +298,9 @@ def test_main_passes_task_table_when_show_tensor_info_enabled(tmp_path, monkeypa captured = {} - def fake_emit_html( - edges, - nodes, - meta, - direction="LR", - engine="dot", - annotations=None, - tensor_table=None, - task_table=None, - show_tensor_info=False, - ): + def fake_emit_html(*args, **kwargs): + task_table = kwargs["task_table"] + show_tensor_info = kwargs["show_tensor_info"] captured["task_table"] = task_table captured["show_tensor_info"] = show_tensor_info return "" @@ -329,17 +334,9 @@ def test_main_keeps_task_metadata_when_show_tensor_info_disabled(tmp_path, monke captured = {} - def fake_emit_html( - edges, - nodes, - meta, - direction="LR", - engine="dot", - annotations=None, - tensor_table=None, - task_table=None, - show_tensor_info=False, - ): + def fake_emit_html(*args, **kwargs): + task_table = kwargs["task_table"] + show_tensor_info = kwargs["show_tensor_info"] captured["task_table"] = task_table captured["show_tensor_info"] = show_tensor_info return "" @@ -353,14 +350,6 @@ def fake_emit_html( assert captured["show_tensor_info"] is False -def test_transitive_reduction_removes_implied_edge(): - # A->B->C plus the implied shortcut A->C. - kept, removed, is_dag = deps_viewer._transitive_reduction([(1, 2), (1, 3), (2, 3)], [1, 2, 3]) - assert is_dag - assert removed == [(1, 3)] - assert kept == [(1, 2), (2, 3)] - - def test_transitive_reduction_keeps_non_redundant_diamond(): # Diamond A->B, A->C, B->D, C->D — no edge is implied by another path. edges = [(1, 2), (1, 3), (2, 4), (3, 4)] @@ -430,6 +419,53 @@ def test_main_full_mode_keeps_all_edges(tmp_path, capsys): assert "Transitive reduction" not in capsys.readouterr().out +def test_main_html_edge_modes_keep_full_layout_and_hide_unselected_edges(tmp_path, monkeypatch): + ring = 1 << 32 + a, b, c = ring + 1, ring + 2, ring + 3 + all_edges = [(a, b), (b, c), (a, c)] + deps_path = _write_deps_edges(tmp_path, all_edges) + captures = [] + + def fake_emit_html(edges, _nodes, _meta, **kwargs): + html_edge_style = kwargs["html_edge_style"] + captures.append( + { + "edges": edges, + "annotations": kwargs["annotations"], + "hidden_edges": html_edge_style["hidden_edges"], + "visible_edge_count": html_edge_style["visible_edge_count"], + } + ) + return "" + + monkeypatch.setattr(deps_viewer, "emit_html", fake_emit_html) + + cases = [ + ("reduced", {(a, c)}, 2), + ("omitted", {(a, b), (b, c)}, 1), + ] + for mode, expected_hidden, expected_visible_count in cases: + rc = deps_viewer.main( + [ + str(deps_path), + "--format", + "html", + "--edge-mode", + mode, + "-o", + str(tmp_path / f"{mode}.html"), + ] + ) + assert rc == 0 + + assert len(captures) == len(cases) + for captured, (_mode, expected_hidden, expected_visible_count) in zip(captures, cases): + assert captured["edges"] == sorted(all_edges) + assert set(captured["annotations"]) == set(all_edges) + assert captured["hidden_edges"] == expected_hidden + assert captured["visible_edge_count"] == expected_visible_count + + def test_main_omitted_mode_draws_only_redundant_edges(tmp_path, capsys): # A(r1t1) -> B(r1t2) -> C(r1t3) plus the implied A->C shortcut. omitted # keeps ONLY the redundant A->C edge (complement of reduced).