|
| 1 | +# 2D Graph Explorer |
| 2 | + |
| 3 | +Builds a synthetic 2D point cloud, builds a Dynamic Exploration Graph over it and opens a window where the |
| 4 | +drawing coordinates are the feature vectors, so every line on screen is a real edge the search can traverse. |
| 5 | + |
| 6 | +## Start |
| 7 | + |
| 8 | +```bash |
| 9 | +cd examples/graph_2d |
| 10 | +uv sync |
| 11 | +uv run main.py |
| 12 | +``` |
| 13 | + |
| 14 | +`uv sync` compiles the local `deglib` bindings from `../../python`, so a C++ compiler and CMake must be on your |
| 15 | +`PATH`. After changing headers under `cpp/`, refresh with `uv sync --reinstall-package deglib`. |
| 16 | + |
| 17 | +## The Search |
| 18 | + |
| 19 | +The example runs its own ε-search in Python (`pysearch.py`), a faithful port of the library's |
| 20 | +`searchImpl`: a min-heap pops the closest candidate, each unvisited neighbour is measured once, kept for |
| 21 | +expansion inside the exploration radius and recorded as an answer when it improves on the worst kept |
| 22 | +distance, the radius tightening by `1 + eps` (or `1 - eps` when it runs below zero, as an inner-product |
| 23 | +search's does). It is held to the C++ search by a parity test — same answer set, same distances, on the |
| 24 | +same entry — and the library's own search is kept only as that reference. |
| 25 | + |
| 26 | +The port exists because the C++ search cannot do two things the viewer needs. It returns the top-`k` |
| 27 | +answers but throws away the walk that reached them, so the port records a predecessor for every vertex it |
| 28 | +queues and reconstructs, per answer, the chain of edges the search itself followed — the route the viewer |
| 29 | +draws. And it only ever runs over a built DEG, whereas the reference graphs are read from an edge list the |
| 30 | +library never sees; the port takes any adjacency list, so the search runs over the graph on screen whether |
| 31 | +that is the DEG or one of the reference graphs. The panel's `route` line reports the run: hops to the top |
| 32 | +answer, distances computed, vertices checked. |
| 33 | + |
| 34 | +## Options |
| 35 | + |
| 36 | +| Argument | Default | Description | |
| 37 | +|---|---|---| |
| 38 | +| `--preset` | `uniform` | Distribution (default `uniform`): `blobs`, `moons`, `circles`, `spiral`, `grid`, `uniform`. | |
| 39 | +| `--vertices` | `200` | Number of points, 10 to 1 200. | |
| 40 | +| `--k` | `4` | Edges per vertex; even and at least 4. | |
| 41 | +| `--metric` | `L2` | Distance the graph is built on; `L2` or `IP`. | |
| 42 | +| `--search-metric` | build metric | Distance the traversal ranks neighbours by; `L2` or `IP`. | |
| 43 | +| `--mips` | `False` | Lift the data into the MIPS→L2 spherical space: a third dimension the graph and searches run on, while the plot stays 2D. | |
| 44 | +| `--seed` | `7` | Seed for the point cloud and the graph build. | |
| 45 | +| `--output` | `None` | Write the graph to this image file. | |
| 46 | +| `--no-show` | `False` | Headless run: print statistics and write `graph_2d.png`. | |
| 47 | + |
| 48 | +Bad input exits with code 2 and a one-line message. |
| 49 | + |
| 50 | +```bash |
| 51 | +uv run main.py --preset spiral --vertices 600 # another distribution and size |
| 52 | +uv run main.py --metric IP # build in inner-product space |
| 53 | +uv run main.py --search-metric IP # walk an L2-built graph by inner product |
| 54 | +uv run main.py --mips # build on the spherical MIPS→L2 lift, plot stays 2D |
| 55 | +uv run main.py --no-show --output deg.png # headless render, no GUI |
| 56 | +``` |
| 57 | + |
| 58 | +## Controls |
| 59 | +The top strip is a row of native Tk widgets, left to right: a `distribution` dropdown, a `vertices` spinbox |
| 60 | +(10 to 1 200, step 100), a `graph` dropdown, a `k` spinbox (edges per vertex), a `mips transform` dropdown, a |
| 61 | +`build metric` dropdown, a `search metric` dropdown, a `top k` spinbox (1 to 8, default 1), and an `eps` |
| 62 | +spinbox (the exploration factor, 0.0 to 10.0, step 0.05). Every dropdown chooses on selection: opening it |
| 63 | +and walking the entries with the arrow keys (or the mouse) applies the value the cursor stands on exactly |
| 64 | +as a click would, and closing the popup keeps that choice. `build metric` decides which neighbours the graph |
| 65 | +stores, `search metric` decides how the traversal ranks them. The `top k` field sets how many results the |
| 66 | +search reports (the panel lists them one per line as `#1 …`, `#2 …`, with the `hit` marker on whichever line |
| 67 | +names the best match, so it marks containment rather than rank one); it is also the size of the result heap |
| 68 | +the search keeps, so it bounds the exploration radius — a wider `top_k` holds the radius loose for longer and |
| 69 | +reaches further into the graph. The `k` field is enabled only while the `graph` selector shows `deg`, |
| 70 | +`knng` or `nsw` and is greyed out otherwise; its bounds depend on the graph — under `knng` and `nsw` it |
| 71 | +accepts any integer 1 to 16, under `deg` it is even-only 4 to 16, an odd value snapping up to the next even. |
| 72 | +The field value is used for the knng and the NSW; the DEG always builds with the snapped even value. The |
| 73 | +`graph` dropdown also offers `knng` (a directed k-nearest-neighbour graph over the current feature space: |
| 74 | +each vertex links its `k` nearest and can only walk the neighbours it chose itself) and `nsw` (a navigable |
| 75 | +small-world graph, built incrementally and undirected — each vertex links its `k` nearest among the vertices |
| 76 | +already present, so a later vertex can raise an earlier one's degree past `k` and the maximum degree is |
| 77 | +unbounded), both available under every metric, and `nsg` (the NSG's monotonic relative neighbourhood graph |
| 78 | +over the current feature space: the directed greedy graph each vertex walks in increasing distance, keeping |
| 79 | +the nearest free neighbour — built by the viewer like the knng and the NSW, so it is offered under every |
| 80 | +metric, but unlike the reference graphs it is never scored against the DEG and reads no `k`), and `none` |
| 81 | +(draws no edges at all). |
| 82 | +The directed knng and NSG are drawn with arrowheads rather than plain strokes: every link ends in a head at the |
| 83 | +vertex it points to, sized in screen pixels so it stays square and fixed through a zoom, which is what makes |
| 84 | +the directed statistics below readable. A `save` button sits to the left of the `help` button, both packed at the right end |
| 85 | +of the strip: `save` writes the graph — the plot alone, its colour key, text panel and controls all cropped |
| 86 | +clear — to a PNG file through a save dialog at 150 dpi, and `help` |
| 87 | +opens a small window of its own listing every binding — it stays out of the plot, since the drawing is the |
| 88 | +reason the window is open. |
| 89 | + |
| 90 | +The text panel names the graph actually on screen and states its shape: under the heading it lists the drawn |
| 91 | +graph's `degree`, its `sources`/`sinks` and its two `reach` numbers — the fraction a breadth-first walk from |
| 92 | +the start node reaches, and the mean fraction reachable from every vertex. These read off the graph the |
| 93 | +search would walk and are phrased by its kind: a directed graph (the knng or the NSG) splits `degree` into its out- and |
| 94 | +in-span and counts `sources` and `sinks` apart, while an undirected graph has one degree per vertex, so it |
| 95 | +shows a single span and names the `isolated` vertices instead. The numbers are recomputed only when the graph |
| 96 | +itself moves, never on a hover. The line under the heading counts the drawn graph's edges and reports how long |
| 97 | +it took to construct — the reference graphs time their build in the comparison, while the `knng` and `nsw` |
| 98 | +are timed as the viewer builds them on first selection, so picking a graph from the dropdown shows its |
| 99 | +construction cost in milliseconds. |
| 100 | + |
| 101 | + |
| 102 | + |
| 103 | +The colour key under the plot is itself a control: clicking an entry withdraws that reference graph's colour |
| 104 | +and the edges it claimed fall through to the next graph holding them, which is how the nesting gets peeled |
| 105 | +apart one layer at a time. `DEG only` names the remainder and is deliberately not a switch — the |
| 106 | +`theory colours` checkbox owns that flag on its own. |
| 107 | + |
| 108 | +| Action | Key | |
| 109 | +|---|---| |
| 110 | +| Select a vertex (left-click) / query the clicked empty space (left-click) / set the traversal start node (right-click) / run the query (double-click) | — | |
| 111 | +| Switch one reference graph's colour on or off | click its key entry | |
| 112 | +| Release the selection or the start node | `Esc` | |
| 113 | +| Next graph | `v` | |
| 114 | +| New sample from the same distribution | `r` | |
| 115 | +| Overlays: coordinate cross, edges, vertex ids, theory colours, query markers | `g` / `e` / `i` / `c` / `q` | |
| 116 | +| Zoom at the cursor / pan by dragging / restore the framing | scroll / drag / `0` | |
| 117 | +| Control sheet | `h` or `?` | |
| 118 | + |
| 119 | +A query takes the selected vertex as the search vector. The Python ε-search answers, scored against the |
| 120 | +query's best match — the true nearest neighbour under the search metric, the vertex itself excluded — and |
| 121 | +carries the route it took to its top answer in the same run. The star marks the best match, the X marks the |
| 122 | +answer the search returned, and the path is the search's own recorded route from the start node to the X. A |
| 123 | +green X on the star means the search recovered the true nearest neighbour; a red X away from the star means |
| 124 | +it answered elsewhere, and a dashed line between them measures the miss. A search that returns nothing |
| 125 | +leaves neither X nor path on screen. Because the search runs over whichever graph is on screen, the route is |
| 126 | +drawn for a reference graph exactly as it is for the DEG. |
| 127 | + |
| 128 | +Clicking empty space instead of a vertex makes the clicked coordinates themselves the query — a query that |
| 129 | +is not a node, drawn as an orange cross. It is searched as itself: nothing is excluded from the ground truth, |
| 130 | +because a free point is never a vertex to skip, so the answer is the best match over the whole cloud. `Esc` |
| 131 | +releases the free query as it releases a vertex selection. |
| 132 | + |
| 133 | +## Two Distances |
| 134 | + |
| 135 | +Keeping the two metrics apart walks the stored edges by a comparator the build never optimised for. The |
| 136 | +edges on screen do not move; only the route the descent takes and the answer it is scored against do, which |
| 137 | +is what makes the difference between the two measurable instead of theoretical. An L2 graph walked by inner |
| 138 | +product leaves its target far more often, and the panel reports the miss against the inner product's own |
| 139 | +optimum, so it is the metrics disagreeing rather than the search failing. |
| 140 | + |
| 141 | +Re-tagging a built graph is cheap here for a specific reason: a read-only graph stores no edge weights, it |
| 142 | +recomputes every distance from its own feature space while the traversal runs. Swapping that space therefore |
| 143 | +keeps the adjacency exactly as drawn and changes only which neighbour the greedy step picks next — about |
| 144 | +0.1 ms at 1200 vertices, and no rebuild. Changing what the graph *stores* is the other dropdown's job and |
| 145 | +does rebuild. |
| 146 | + |
| 147 | +## MIPS Transform |
| 148 | + |
| 149 | +The `mips transform` dropdown (and `--mips`) lifts every point onto a sphere by appending a third coordinate, |
| 150 | +`x' = [x, √(M² − ‖x‖²)]` with `M` the largest norm in the cloud, so an inner-product search over the original |
| 151 | +cloud becomes an L2 search over the lifted one. The graph build, the searches, the ground truth and the |
| 152 | +reference graphs all run on those 3D features, while the plot keeps drawing the original 2D cloud — the |
| 153 | +picture never changes shape, only the space the graph is navigated in. A query is answered from the |
| 154 | +original vector zero-padded to `[x, y, 0]` (not the vertex's own lifted coordinate), so the search is a |
| 155 | +true MIPS query: its nearest lifted neighbour is the original that maximises the inner product. |
| 156 | + |
| 157 | +Delaunay drops out of the reference graphs under the transform. It triangulates the two drawing coordinates, |
| 158 | +so on the lifted features it would silently report the Delaunay graph of the projection, and a genuine 3D |
| 159 | +triangulation is degenerate because the transform places every vector at the same norm. The Gabriel, RNG, |
| 160 | +MRNG and MST graphs are read from the distance matrix alone and stay valid in three dimensions, so they |
| 161 | +remain. |
| 162 | + |
| 163 | +## Tests |
| 164 | + |
| 165 | +```bash |
| 166 | +uv run pytest |
| 167 | +``` |
0 commit comments