Skip to content

Commit 5a77038

Browse files
committed
feat(example): 2d graph example
1 parent 4f09ec3 commit 5a77038

12 files changed

Lines changed: 7656 additions & 1 deletion

File tree

‎examples/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ uv sync --reinstall-package deglib
4040

4141
## Projects
4242

43+
- [`graph_2d`](./graph_2d/): interactive 2D explorer — builds a DEG on a synthetic cloud, traces a traversal on screen and compares the result against the Delaunay graph, RNG and MRNG.
4344
- [`knng`](./knng/): k-Nearest Neighbor Graph (k-NNG) construction benchmark using EVP quantization and FP16 reranking (SISAP 2026 Challenge Task 1).
4445
- [`mips`](./mips/): Maximum Inner Product Search (MIPS) benchmark using $(d+1)$-dimensional $L_2$ transformation, FLAS pre-sorting, and SIMD FP16 inner products (SISAP 2026 Challenge Task 2).
4546
- [`static_data`](./static_data/): DEG paper search benchmark reproduction (Recall vs. QPS) on static datasets (`sift1m`, `deep1m`, `glove-100`, `audio`, `enron`).

‎examples/graph_2d/README.md‎

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
```

‎examples/graph_2d/dataset.py‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Synthetic two-dimensional point clouds used as graph input for this example."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
7+
__all__ = ["MAX_POINTS", "MIN_POINTS", "PRESETS", "make_points"]
8+
9+
#: Synthetic 2D distributions the graph can be built on.
10+
PRESETS: tuple[str, ...] = ("blobs", "moons", "circles", "spiral", "grid", "uniform")
11+
12+
#: Smallest and largest cloud the example accepts. The upper bound is not a memory limit but a runtime
13+
#: one: the theoretical reference graphs in `theory.py` compare the DEG against the Delaunay graph, the
14+
#: RNG and the MRNG, and their cost grows faster than the point count.
15+
MIN_POINTS = 10
16+
MAX_POINTS = 1200
17+
18+
19+
def _blobs(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
20+
"""Isotropic Gaussian clusters with per-cluster spread."""
21+
clusters = int(np.clip(num_points // 200, 3, 8))
22+
centers = rng.uniform(0.0, 10.0, size=(clusters, 2))
23+
spread = rng.uniform(0.25, 0.75, size=(clusters, 1))
24+
group = rng.integers(0, clusters, size=num_points)
25+
return centers[group] + rng.standard_normal((num_points, 2)) * spread[group], group
26+
27+
28+
def _moons(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
29+
"""Two interleaved half-moons, the classic non-linearly-separable toy set."""
30+
group = rng.integers(0, 2, size=num_points)
31+
theta = rng.uniform(0.0, np.pi, size=num_points)
32+
x = np.where(group == 0, np.cos(theta), 1.0 - np.cos(theta)) * 4.0
33+
y = np.where(group == 0, np.sin(theta), 0.5 - np.sin(theta)) * 4.0
34+
points = np.column_stack([x, y]) + rng.standard_normal((num_points, 2)) * 0.22
35+
return points, group
36+
37+
38+
def _circles(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
39+
"""Concentric rings with radial noise."""
40+
rings = np.array([1.2, 2.6, 4.2])
41+
group = rng.integers(0, rings.size, size=num_points)
42+
theta = rng.uniform(0.0, 2.0 * np.pi, size=num_points)
43+
radius = rings[group] + rng.standard_normal(num_points) * 0.09
44+
return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]), group
45+
46+
47+
def _spiral(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
48+
"""Multiple spiral arms wound around the origin."""
49+
arms = 3
50+
group = np.arange(num_points) % arms
51+
radius = np.sqrt(rng.uniform(0.0, 1.0, size=num_points)) * 4.5
52+
theta = radius * 1.35 + group * (2.0 * np.pi / arms)
53+
points = np.column_stack([radius * np.cos(theta), radius * np.sin(theta)])
54+
return points + rng.standard_normal((num_points, 2)) * 0.07, group
55+
56+
57+
def _grid(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
58+
"""Jittered square lattice, grouped by cell parity."""
59+
side = int(np.ceil(np.sqrt(num_points)))
60+
step = 10.0 / side
61+
cell = np.arange(num_points)
62+
column, row = cell % side, cell // side
63+
group = (column + row) % 2
64+
points = np.column_stack([column, row]).astype(np.float64) * step
65+
return points + rng.standard_normal((num_points, 2)) * (step * 0.22), group
66+
67+
68+
def _uniform(num_points: int, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]:
69+
"""Homogeneous fill of the square — the structureless baseline."""
70+
return rng.uniform(0.0, 10.0, size=(num_points, 2)), np.zeros(num_points, dtype=np.int64)
71+
72+
73+
_GENERATORS = {
74+
"blobs": _blobs,
75+
"moons": _moons,
76+
"circles": _circles,
77+
"spiral": _spiral,
78+
"grid": _grid,
79+
"uniform": _uniform,
80+
}
81+
82+
83+
def make_points(preset: str, num_points: int, seed: int = 7) -> tuple[np.ndarray, np.ndarray]:
84+
"""
85+
Generates a synthetic 2D point cloud.
86+
87+
Returns `(points, groups)` where `points` is a `[n, 2]` float32 array — the DEG feature
88+
vectors and the drawing coordinates at once — and `groups` is a `[n]` int8 array holding
89+
the generating cluster, used only for coloring.
90+
91+
Every cloud is shifted so the middle of its bounding box sits at the origin, which makes the
92+
coordinates straddle zero. Distances are untouched by a translation, so the graph is the same.
93+
"""
94+
if preset not in _GENERATORS:
95+
raise ValueError(f"Unknown preset '{preset}'. Available: {', '.join(PRESETS)}")
96+
if not MIN_POINTS <= num_points <= MAX_POINTS:
97+
raise ValueError(f"A preset needs between {MIN_POINTS} and {MAX_POINTS} points, got {num_points}")
98+
99+
points, group = _GENERATORS[preset](int(num_points), np.random.default_rng(seed))
100+
points = points - (points.min(axis=0) + points.max(axis=0)) / 2.0
101+
points = np.ascontiguousarray(points, dtype=np.float32)
102+
if not np.isfinite(points).all():
103+
raise AssertionError(f"Preset '{preset}' produced non-finite coordinates")
104+
return points, np.ascontiguousarray(group, dtype=np.int8)

0 commit comments

Comments
 (0)