Skip to content

Commit dba2f16

Browse files
cdeustclaude
andcommitted
feat(viz): server-tile + Datashader path for >1M-node graphs (CPU-only, no CUDA)
The d3-force/canvas viz tops out around 5-10K nodes; even with batched loading and pinning it cannot scale to the 300K+ Cortex now emits, let alone the 1M-10M target. This commit lays in a CPU-only tile-streaming pipeline alongside the existing renderer, gated by ``?viz=tilemap`` so the legacy path stays the default until the new one is hardened. Architecture (3 plans in tasks/*.md): Layout — tasks/layout-cache-plan.md (igraph DrL, CPU) Tiles — tasks/tile-server-plan.md (Datashader, CPU) Frontend — tasks/tilemap-frontend-plan.md (deck.gl, no CUDA) Backend additions ───────────────── * core/layout_engine.py — igraph DrL wrapper. Pure logic. * core/tile_renderer.py — Datashader points→PNG with the fixed kind palette + XYZ tile bbox mapping over [-1, 1] world coords. * infrastructure/layout_pg_store.py — bulk insert + bbox-range read against the new ``workflow_graph_layout`` table. * handlers/recompute_layout.py — /api/recompute_layout: pulls the cached graph, runs DrL, persists. * handlers/tile_handler.py — /api/tile/{z}/{x}/{y}.png: pulls positions in tile bbox, renders. * handlers/quadtree_handler.py — /api/quadtree: gzipped Arrow IPC of every (id, x, y, kind) for client flatbush picking. * server/http_standalone.py — three new GET routes wired in. * infrastructure/pg_schema.py — new ``workflow_graph_layout`` table with B-tree indexes on (layout_version, kind, x, y). Frontend additions ────────────────── * ui/unified/js/workflow_graph_tilemap.js — deck.gl TileLayer + OrthographicView (Cartesian, no projection); flatbush index built from the Arrow quadtree; hover label + click → existing side panel. * ui/unified/js/workflow_graph.js — ``?viz=tilemap`` gate at the top of renderWorkflowGraph; legacy d3-force is the default. * ui/unified-viz.html — load the tilemap module. Optional dependencies ───────────────────── New ``viz-tile`` extra in pyproject.toml: * igraph — CPU layout (MIT, prebuilt wheels) * datashader — tile rasterization (BSD, O(viewport pixels)) * pyarrow — Arrow IPC for the quadtree endpoint * pandas — Datashader's native frame format * cachetools — L1 in-memory tile LRU (PR 2 will use it) * Pillow — PNG encode NOT installed by default — keeps the package install size unchanged for users who don't need the >1M-node viz. Frontend pulls deck.gl 9, Apache Arrow JS 17, and Flatbush 4 from unpkg on first load; vendoring under deps/ deferred to PR 2. Smallest-defensible-v1 scope ──────────────────────────── * No tile cache (L1 LRU + L2 disk) — both planned, deferred. * No edge rendering in tiles — points only; edges become noise at 1M+ scale and are better surfaced via on-click context. * No domain/kind filter on tile or quadtree URLs — single global view for v1. * Synchronous /api/recompute_layout (long request) — async job deferred to PR 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 761b023 commit dba2f16

12 files changed

Lines changed: 937 additions & 0 deletions

mcp_server/core/layout_engine.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""CPU layout engine for the workflow graph.
2+
3+
Pure logic: takes a list of node ids + an edge list, returns a list of
4+
``(node_id, x, y)`` triples. Calls ``igraph`` (MIT, prebuilt PyPI
5+
wheels for macOS/Linux/Windows) for the actual layout. No I/O, no
6+
PostgreSQL imports — this module is testable with synthetic graphs.
7+
8+
Algorithm choice — DrL (Distributed Recursive Layout):
9+
* O(N log N) per iteration, scales linearly with edge count.
10+
* Tuned for force-directed exploratory views; produces well-separated
11+
clusters even on 1M-node graphs in under 3 minutes on a modern CPU.
12+
* Falls back to Fruchterman-Reingold for tiny graphs (<200 nodes)
13+
where DrL's bookkeeping overhead is wasted.
14+
15+
Reference: Martin et al. "OpenOrd: An Open-Source Toolbox for Large
16+
Graph Layout", SPIE 2011 — DrL is the OpenOrd algorithm under its
17+
original name.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import hashlib
23+
from typing import Iterable
24+
25+
26+
def topology_fingerprint(node_ids: Iterable[str], edges: Iterable[tuple]) -> str:
27+
"""Stable fingerprint of the graph's topology, used as a cache key.
28+
29+
A graph's layout is valid as long as the same set of node ids and
30+
the same set of (source, target) pairs are present. The
31+
fingerprint is a SHA-256 over the sorted concatenation; two builds
32+
with the same topology — even with different memory contents —
33+
share a fingerprint and reuse the same coords.
34+
"""
35+
h = hashlib.sha256()
36+
for nid in sorted(node_ids):
37+
h.update(nid.encode("utf-8"))
38+
h.update(b"\n")
39+
h.update(b"--edges--\n")
40+
edge_strs = sorted(f"{s}\x00{t}" for s, t in edges)
41+
for e in edge_strs:
42+
h.update(e.encode("utf-8"))
43+
h.update(b"\n")
44+
return h.hexdigest()[:16]
45+
46+
47+
def layout(
48+
node_ids: list[str],
49+
edges: list[tuple[str, str]],
50+
*,
51+
algorithm: str = "drl",
52+
seed: int = 0,
53+
) -> list[tuple[str, float, float]]:
54+
"""Compute (x, y) per node and return ``[(id, x, y), ...]``.
55+
56+
Raises:
57+
ImportError: if ``igraph`` is not installed (the optional
58+
``viz-tile`` extra). The caller is expected to surface this
59+
as a 503 on the HTTP endpoint.
60+
ValueError: if ``node_ids`` is empty.
61+
"""
62+
try:
63+
import igraph as ig
64+
except ImportError as exc: # pragma: no cover
65+
raise ImportError(
66+
"igraph is required for layout — install the 'viz-tile' extra: "
67+
"pip install neuro-cortex-memory[viz-tile]"
68+
) from exc
69+
70+
if not node_ids:
71+
raise ValueError("layout requires at least one node id")
72+
73+
# Build an integer-indexed igraph from the string ids. ``igraph``'s
74+
# vertex API accepts string names but the layout algorithms operate
75+
# on contiguous integer indices, so we pre-translate.
76+
idx_of = {nid: i for i, nid in enumerate(node_ids)}
77+
edge_pairs = [
78+
(idx_of[s], idx_of[t])
79+
for s, t in edges
80+
if s in idx_of and t in idx_of and s != t
81+
]
82+
g = ig.Graph(n=len(node_ids), edges=edge_pairs, directed=False)
83+
g.simplify()
84+
85+
if algorithm == "drl" and len(node_ids) >= 200:
86+
coords = g.layout("drl")
87+
elif algorithm == "fr" or len(node_ids) < 200:
88+
coords = g.layout_fruchterman_reingold(niter=200, seed=seed)
89+
else:
90+
# Defensive default: DrL is the expected path; fall back to FR
91+
# for any unknown algorithm name rather than raising.
92+
coords = g.layout("drl")
93+
94+
raw = list(coords.coords)
95+
if not raw:
96+
return []
97+
98+
# Normalise into [-1, 1] world coords (the tile renderer + the
99+
# client coordinate system both assume this range).
100+
xs = [p[0] for p in raw]
101+
ys = [p[1] for p in raw]
102+
min_x, max_x = min(xs), max(xs)
103+
min_y, max_y = min(ys), max(ys)
104+
span_x = (max_x - min_x) or 1.0
105+
span_y = (max_y - min_y) or 1.0
106+
span = max(span_x, span_y) * 0.55 # slight padding inside [-1, 1]
107+
cx = (min_x + max_x) / 2
108+
cy = (min_y + max_y) / 2
109+
110+
return [
111+
(node_ids[i], (raw[i][0] - cx) / span, (raw[i][1] - cy) / span)
112+
for i in range(len(node_ids))
113+
]

mcp_server/core/tile_renderer.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Datashader-backed tile rendering for the workflow graph.
2+
3+
Pure rendering logic: takes a list of ``(node_id, x, y, kind)`` tuples
4+
plus tile coordinates and produces PNG bytes. No PostgreSQL imports —
5+
the handler layer composes this with ``layout_pg_store.read_positions_in_bbox``.
6+
7+
World coordinate system: ``[-1, 1] × [-1, 1]``. The layout engine
8+
normalises into this range; the tile pyramid maps each ``(z, x, y)`` to
9+
a sub-rectangle of the world.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import io
15+
16+
# Fixed palette. Palette changes invalidate the tile cache via
17+
# ``PALETTE_VERSION`` — bump it whenever any colour below changes.
18+
PALETTE_VERSION = 1
19+
KIND_COLOR_HEX = {
20+
"domain": "#FCD34D",
21+
"tool_hub": "#F97316",
22+
"skill": "#FB923C",
23+
"command": "#FACC15",
24+
"hook": "#A855F7",
25+
"agent": "#EC4899",
26+
"mcp": "#6366F1",
27+
"memory": "#10B981",
28+
"discussion": "#EF4444",
29+
"entity": "#50B0C8",
30+
"file": "#06B6D4",
31+
"symbol": "#64748B",
32+
}
33+
DEFAULT_HEX = "#888888"
34+
35+
WORLD_MIN = -1.0
36+
WORLD_MAX = 1.0
37+
38+
39+
def tile_world_bbox(z: int, x: int, y: int) -> tuple[float, float, float, float]:
40+
"""Map XYZ tile coordinates to ``(min_x, min_y, max_x, max_y)``.
41+
42+
z=0 covers the whole [-1, 1] world in a single tile. Each level
43+
halves the tile span so z=10 has ~1M tiles and a per-tile span of
44+
2/1024 ≈ 0.002 in world units (sub-pixel at any practical zoom).
45+
"""
46+
span = (WORLD_MAX - WORLD_MIN) / (1 << z)
47+
min_x = WORLD_MIN + x * span
48+
max_x = min_x + span
49+
# Tile y axis runs top-down (XYZ convention); flip so the world's
50+
# +y is the top of tile (0,0) at every zoom.
51+
max_y_world = WORLD_MAX - y * span
52+
min_y_world = max_y_world - span
53+
return (min_x, min_y_world, max_x, max_y_world)
54+
55+
56+
def render_tile_png(
57+
rows: list[tuple[str, float, float, str]],
58+
*,
59+
z: int,
60+
x: int,
61+
y: int,
62+
tile_size: int = 512,
63+
) -> bytes:
64+
"""Rasterise ``rows`` (already prefiltered to the tile's world bbox)
65+
into a ``tile_size × tile_size`` PNG.
66+
67+
Returns PNG bytes ready for HTTP transport. Empty input renders a
68+
fully-transparent tile (still valid PNG; client compositors expect
69+
that).
70+
"""
71+
try:
72+
import datashader as ds
73+
import datashader.transfer_functions as tf
74+
import pandas as pd
75+
except ImportError as exc: # pragma: no cover
76+
raise ImportError(
77+
"datashader + pandas are required for tile rendering — install "
78+
"the 'viz-tile' extra: pip install neuro-cortex-memory[viz-tile]"
79+
) from exc
80+
81+
min_x, min_y, max_x, max_y = tile_world_bbox(z, x, y)
82+
if not rows:
83+
# Empty frame still produces a valid 512×512 transparent PNG
84+
# via Datashader's spread([]) → to_pil().
85+
return _empty_tile_png(tile_size)
86+
87+
# Datashader requires categorical kind to be a pandas Categorical
88+
# for ``count_cat`` aggregation. Pre-encode here.
89+
df = pd.DataFrame(rows, columns=["id", "x", "y", "kind"])
90+
kinds_present = sorted(df["kind"].unique())
91+
df["kind"] = pd.Categorical(df["kind"], categories=kinds_present)
92+
93+
canvas = ds.Canvas(
94+
plot_width=tile_size,
95+
plot_height=tile_size,
96+
x_range=(min_x, max_x),
97+
y_range=(min_y, max_y),
98+
)
99+
agg = canvas.points(df, x="x", y="y", agg=ds.count_cat("kind"))
100+
color_key = {k: KIND_COLOR_HEX.get(k, DEFAULT_HEX) for k in kinds_present}
101+
img = tf.shade(agg, color_key=color_key, how="eq_hist")
102+
img = tf.spread(img, px=1, shape="circle")
103+
pil = img.to_pil()
104+
buf = io.BytesIO()
105+
pil.save(buf, format="PNG", optimize=False)
106+
return buf.getvalue()
107+
108+
109+
def _empty_tile_png(tile_size: int) -> bytes:
110+
"""Cheap transparent PNG, used when the bbox query returns no rows."""
111+
try:
112+
from PIL import Image
113+
except ImportError as exc: # pragma: no cover
114+
raise ImportError("Pillow is required for tile rendering") from exc
115+
img = Image.new("RGBA", (tile_size, tile_size), (0, 0, 0, 0))
116+
buf = io.BytesIO()
117+
img.save(buf, format="PNG", optimize=False)
118+
return buf.getvalue()
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""GET /api/quadtree — gzipped Arrow IPC of every node's (id, x, y, kind).
2+
3+
The client builds a quadtree (e.g. flatbush) from this payload to
4+
resolve hover/click locally in O(log N) without a server roundtrip.
5+
``id`` and ``kind`` are dictionary-encoded so the wire size is
6+
dominated by two Float32 columns at 1M nodes ≈ 8 MB raw / ~3-4 MB
7+
gzipped.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import gzip
13+
import json
14+
15+
16+
def serve(handler, store) -> None:
17+
try:
18+
import pyarrow as pa
19+
import pyarrow.ipc as ipc
20+
from mcp_server.infrastructure import layout_pg_store
21+
except ImportError as exc:
22+
body = (
23+
f'{{"status":"error","reason":"viz_tile_extra_missing","detail":"{exc}"}}'
24+
).encode("utf-8")
25+
handler.send_response(503)
26+
handler.send_header("Content-Type", "application/json")
27+
handler.send_header("Content-Length", str(len(body)))
28+
handler.end_headers()
29+
handler.wfile.write(body)
30+
return
31+
32+
rows = layout_pg_store.read_all_positions(store)
33+
if not rows:
34+
body = json.dumps({"status": "error", "reason": "no_layout"}).encode("utf-8")
35+
handler.send_response(503)
36+
handler.send_header("Content-Type", "application/json")
37+
handler.send_header("Content-Length", str(len(body)))
38+
handler.end_headers()
39+
handler.wfile.write(body)
40+
return
41+
42+
ids = [r[0] for r in rows]
43+
xs = [r[1] for r in rows]
44+
ys = [r[2] for r in rows]
45+
kinds = [r[3] for r in rows]
46+
47+
# Dict-encoded id + kind shrink the wire substantially: ``id`` is
48+
# high-cardinality but the dict encoding still beats UTF-8 for
49+
# lookup; ``kind`` collapses to ~12 distinct values.
50+
table = pa.table(
51+
{
52+
"id": pa.array(ids).dictionary_encode(),
53+
"x": pa.array(xs, type=pa.float32()),
54+
"y": pa.array(ys, type=pa.float32()),
55+
"kind": pa.array(kinds).dictionary_encode(),
56+
}
57+
)
58+
59+
sink = pa.BufferOutputStream()
60+
with ipc.new_stream(sink, table.schema) as writer:
61+
writer.write_table(table)
62+
arrow_buf = sink.getvalue().to_pybytes()
63+
body = gzip.compress(arrow_buf, compresslevel=6)
64+
65+
handler.send_response(200)
66+
handler.send_header("Content-Type", "application/vnd.apache.arrow.stream")
67+
handler.send_header("Content-Encoding", "gzip")
68+
handler.send_header("Content-Length", str(len(body)))
69+
handler.send_header("Cache-Control", "max-age=60")
70+
handler.end_headers()
71+
handler.wfile.write(body)

0 commit comments

Comments
 (0)