| import math |
| from typing import List |
|
|
| from dash import Dash, html, dcc, Input, Output, State, ctx, no_update |
| import plotly.graph_objects as go |
|
|
|
|
| |
| |
| |
| DEFAULTS = { |
| "dimension": 3, |
| "scale": 1600.0, |
| "node_radius": 6, |
| "edge_width": 4, |
| "show_labels": False, |
| "label_fontsize": 12, |
| "max_dimension": 12, |
| "min_dimension": 1, |
| } |
|
|
|
|
|
|
| |
|
|
| def swap_dims_vertex(v: int, i: int, j: int) -> int: |
| """ |
| Swap bits i and j in the vertex id v. |
| (Coordinate permutation on Q_d.) |
| """ |
| if i == j: |
| return v |
| bi = (v >> i) & 1 |
| bj = (v >> j) & 1 |
| if bi != bj: |
| |
| v ^= (1 << i) | (1 << j) |
| return v |
|
|
|
|
| def flip_dim_vertex(v: int, k: int) -> int: |
| """ |
| Flip bit k in the vertex id v. |
| (Translation by the unit vector in coordinate k.) |
| """ |
| return v ^ (1 << k) |
|
|
|
|
| def swap_dims_path(path, d: int, i: int, j: int): |
| """Apply swap_dims_vertex to every vertex in the path.""" |
| if i < 0 or j < 0 or i >= d or j >= d: |
| return path |
| return [swap_dims_vertex(v, i, j) for v in path] |
|
|
|
|
| def flip_dim_path(path, d: int, k: int): |
| """Apply flip_dim_vertex to every vertex in the path.""" |
| if k < 0 or k >= d: |
| return path |
| return [flip_dim_vertex(v, k) for v in path] |
|
|
|
|
| def classify_path(path, d: int): |
| """ |
| Classify a path in Q_d as one of: |
| - "snake" (induced simple path) |
| - "coil" (induced simple cycle) |
| - "almost coil" (open path that would be a coil if closed) |
| - "not snake" otherwise |
| Returns: (label, is_valid) |
| """ |
|
|
| if not path or len(path) <= 1: |
| return "snake", True |
|
|
| |
| is_closed = (path[0] == path[-1]) |
|
|
| |
| cycle = path[:-1] if is_closed else path[:] |
| n = len(cycle) |
|
|
| |
| if len(set(cycle)) != n: |
| return "not snake", False |
|
|
| |
| for i in range(n - 1): |
| if hamming_dist(cycle[i], cycle[i + 1]) != 1: |
| return "not snake", False |
|
|
| |
| closing_adjacent = (hamming_dist(cycle[0], cycle[-1]) == 1) |
|
|
| |
| if is_closed and not closing_adjacent: |
| return "not snake", False |
|
|
| |
| |
| |
| |
| |
| for i in range(n): |
| for j in range(i + 1, n): |
| |
| if j == i + 1: |
| continue |
| |
| if i == 0 and j == n - 1: |
| continue |
| if hamming_dist(cycle[i], cycle[j]) == 1: |
| return "not snake", False |
|
|
| |
|
|
| if is_closed: |
| |
| return "coil", True |
|
|
| |
| if closing_adjacent: |
| |
| return "almost coil", True |
|
|
| return "snake", True |
|
|
|
|
| def hamming_dist(a: int, b: int) -> int: |
| x, c = a ^ b, 0 |
| while x: |
| c += x & 1 |
| x >>= 1 |
| return c |
|
|
|
|
| def build_hypercube(d: int): |
| n = 1 << d |
| nodes = list(range(n)) |
| edges = [] |
| for u in range(n): |
| for bit in range(d): |
| v = u ^ (1 << bit) |
| if u < v: |
| edges.append((u, v, bit)) |
| return nodes, edges |
|
|
|
|
| def dim_color(k: int) -> str: |
| hues = [210, 20, 140, 80, 0, 260, 40, 180, 320, 120] |
| h = hues[k % len(hues)] |
| return f"hsl({h},65%,45%)" |
|
|
|
|
| def int_to_bin(n: int, d: int) -> str: |
| return format(n, f"0{d}b") |
|
|
| |
| |
| def layout_positions(d: int, base: float = 900.0): |
| n = 1 << d |
| dx, dy = [0.0] * d, [0.0] * d |
| for k in range(d): |
| tier = k // 2 |
| mag = (2 ** max(0, (d - 1) - tier)) * (base / (2 ** d)) |
| if k % 2 == 0: |
| dx[k] = mag |
| else: |
| dy[k] = mag |
|
|
| pts = [] |
| minx = miny = float("inf") |
| maxx = maxy = float("-inf") |
| for vid in range(n): |
| x = sum(dx[k] for k in range(d) if (vid >> k) & 1) |
| y = sum(dy[k] for k in range(d) if (vid >> k) & 1) |
| if (vid >> 2) & 1: |
| x += 100 |
| y += 250 |
| if (vid >> 3) & 1: |
| x += 1800 |
| y -= 200 |
| if (vid >> 4) & 1: |
| x += 200 |
| y += 1800 |
| if (vid >> 5) & 1: |
| x += 3800 |
| y += 3200 |
| if (vid >> 6) & 1: |
| x += 3400 |
| y -= 200 |
|
|
| pts.append((vid, x, y)) |
| minx, maxx = min(minx, x), max(maxx, x) |
| miny, maxy = min(miny, y), max(maxy, y) |
|
|
| pts2 = [(vid, x - minx, y - miny) for vid, x, y in pts] |
| width = maxx - minx |
| height = maxy - miny |
| return pts2, width, height |
|
|
|
|
| def longest_cib(d: int): |
| """ |
| Return a predefined coil/snake for certain dimensions, |
| otherwise fall back to a standard Gray-code path. |
| """ |
| presets = { |
| 2: [0, 1, 3, 2, 0], |
| 3: [0, 1, 3, 7, 6, 4, 0], |
| 4: [0, 1, 3, 7, 6, 14, 10, 8, 0], |
| 5: [0, 1, 3, 7, 6, 14, 12, 13, 29, 31, 27, 26, 18, 16, 0], |
| 6: [0, 1, 3, 7, 15, 31, 29, 25, 24, 26, 10, 42, 43, 59, 51, 49, 53, 37, 45, 44, 60, 62, 54, 22, 20, 4, 0], |
| 7: [0, 1, 3, 7, 15, 13, 12, 28, 30, 26, 27, 25, 57, 56, 40, 104, 72, 73, 75, 107, 111, 110, 46, 38, 36, 52,116, 124, 125, 93, 95, 87, 119, 55, 51, 50, 114, 98, 66, 70, 68, 69, 101, 97, 113, 81, 80, 16, 0], |
| 8: [0, 1, 3, 7, 6, 14, 12, 13, 29, 31, 27, 26, 18, 50, 54, 62, 60, 56, 57, 49, 53, 37, 101, 69, 68, 196, 132, 133, 149, 151, 150, 158, 156, 220, 92, 94, 86, 87, 119, 115, 123, 122, 250, 254, 255, 191, 187, 179, 163, 167, 231, 230, 226, 98, 66, 74, 202, 200, 136, 137, 139, 143, 207, 205, 237, 173, 172, 174, 170, 42, 43, 47, 111, 110, 108, 104, 105, 73, 89, 217, 219, 211, 195, 193, 225, 241, 245, 244, 116, 112, 80, 208, 144, 176, 160, 32, 0], |
| } |
|
|
| if d in presets: |
| return presets[d][:] |
| |
| return [0] |
| |
| n = 1 << d |
| seq = [] |
| for i in range(n): |
| g = i ^ (i >> 1) |
| seq.append(g) |
| return seq |
|
|
|
|
| def shorter_cib(d: int): |
| """ |
| Return a predefined coil/snake for certain dimensions, |
| otherwise fall back to a standard Gray-code path. |
| """ |
| presets = { |
| 2: [0, 1, 3, 2, 0], |
| 3: [0, 1, 3, 7, 6, 4, 0], |
| 4: [0, 1, 3, 7, 6, 4, 0], |
| 5: [0, 1, 3, 7, 6, 14, 12, 13, 29, 21, 20, 16, 0], |
| 6: [0, 1, 3, 7, 6, 14, 10, 26, 18, 50, 54, 52, 20, 28, 29, 13, 45, 47, 43, 59, 57, 56, 40, 32, 0], |
| |
| 7: [0], |
| 8: [0], |
| } |
|
|
| if d in presets: |
| return presets[d][:] |
| |
| return [0] |
| |
| n = 1 << d |
| seq = [] |
| for i in range(n): |
| g = i ^ (i >> 1) |
| seq.append(g) |
| return seq |
|
|
|
|
|
|
| def parse_path(text: str, d: int) -> List[int]: |
| if not text: |
| return [] |
| out = [] |
| for tok in [t for t in text.replace(",", " ").split() if t]: |
| if False and all(c in "01" for c in tok): |
| val = int(tok, 2) |
| elif tok.isdigit(): |
| val = int(tok) |
| else: |
| continue |
| if 0 <= val < (1 << d): |
| out.append(val) |
| return out |
|
|
| |
|
|
| def make_figure(d: int, |
| show_bits: bool, |
| show_ints: bool, |
| mark_negations: bool, |
| node_r: int, |
| edge_w: int, |
| path: List[int], |
| scale_base: float): |
| nodes, edges = build_hypercube(d) |
| pts, width, height = layout_positions(d, base=scale_base) |
| pos = {vid: (x, y) for vid, x, y in pts} |
|
|
| path = path or [] |
| in_path = set(path) |
|
|
| |
| neg_set = set() |
| neg_edges = set() |
| if mark_negations and path: |
| mask = (1 << d) - 1 |
|
|
| |
| for v in path: |
| neg_set.add(mask ^ v) |
|
|
| |
| for i in range(len(path) - 1): |
| a = path[i] |
| b = path[i + 1] |
| na = mask ^ a |
| nb = mask ^ b |
| key = (min(na, nb), max(na, nb)) |
| neg_edges.add(key) |
|
|
| |
| edge_traces = [] |
|
|
| for bit in range(d): |
| xs, ys, cd = [], [], [] |
| for (u, v, b) in edges: |
| if b != bit: |
| continue |
| x1, y1 = pos[u] |
| x2, y2 = pos[v] |
| xs += [x1, x2, None] |
| ys += [y1, y2, None] |
| cd += [[u, v], [u, v], None] |
| edge_traces.append( |
| go.Scatter( |
| x=xs, |
| y=ys, |
| mode="lines", |
| line=dict(width=edge_w, color=dim_color(bit)), |
| opacity=0.35, |
| hoverinfo="skip", |
| name=f"bit {bit}", |
| customdata=cd, |
| ) |
| ) |
|
|
| |
| path_edges = set() |
| for i in range(len(path) - 1): |
| a, b = path[i], path[i + 1] |
| key = (min(a, b), max(a, b)) |
| path_edges.add(key) |
|
|
| if path_edges: |
| for (u, v, b) in edges: |
| key = (min(u, v), max(u, v)) |
| if key in path_edges: |
| x1, y1 = pos[u] |
| x2, y2 = pos[v] |
| edge_traces.append( |
| go.Scatter( |
| x=[x1, x2], |
| y=[y1, y2], |
| mode="lines", |
| line=dict(width=max(1, edge_w * 3), color=dim_color(b)), |
| opacity=1.0, |
| hoverinfo="skip", |
| name=f"path bit {b}", |
| ) |
| ) |
|
|
| |
| if mark_negations and neg_edges: |
| for (u, v) in neg_edges: |
| x1, y1 = pos[u] |
| x2, y2 = pos[v] |
| edge_traces.append( |
| go.Scatter( |
| x=[x1, x2], |
| y=[y1, y2], |
| mode="lines", |
| line=dict(width=max(1, edge_w * 3), color="red"), |
| opacity=1.0, |
| hoverinfo="skip", |
| name="negated path", |
| ) |
| ) |
|
|
| |
| xs = [pos[v][0] for v in nodes] |
| ys = [pos[v][1] for v in nodes] |
|
|
| bit_labels = [int_to_bin(v, d) for v in nodes] |
| int_labels = [str(v) for v in nodes] |
|
|
| show_any_label = show_bits or show_ints |
| if show_bits and show_ints: |
| texts = [f"{iv}\n{bv}" for iv, bv in zip(int_labels, bit_labels)] |
| elif show_bits: |
| texts = bit_labels |
| elif show_ints: |
| texts = int_labels |
| else: |
| texts = None |
|
|
| sizes = [] |
| colors = [] |
| for v in nodes: |
| base_size = node_r * 2 |
| sizes.append(base_size * (3 if v in in_path else 1)) |
|
|
| if path and (v == path[0] or v == path[-1]): |
| colors.append("#111") |
| elif mark_negations and v in neg_set: |
| colors.append("red") |
| else: |
| colors.append("#222") |
|
|
| node_trace = go.Scatter( |
| x=xs, |
| y=ys, |
| mode="markers+text" if show_any_label else "markers", |
| marker=dict(size=sizes, color=colors, line=dict(width=1, color="#333")), |
| text=texts, |
| textposition="middle right", |
| textfont=dict(size=12, color="#333"), |
| hovertemplate=( |
| "id=%{customdata}<br>label=%{text}<extra></extra>" if show_any_label |
| else "id=%{customdata}<extra></extra>" |
| ), |
| customdata=nodes, |
| name="vertices", |
| ) |
|
|
| fig = go.Figure(edge_traces + [node_trace]) |
| pad = max(40, 0.08 * max(width, height)) |
| fig.update_layout( |
| showlegend=False, |
| margin=dict(l=20, r=20, t=40, b=20), |
| xaxis=dict(visible=False, range=[-pad, width + pad]), |
| yaxis=dict( |
| visible=False, |
| scaleanchor="x", |
| scaleratio=1, |
| range=[-pad, height + pad], |
| ), |
| dragmode=False, |
| template="plotly_white", |
| ) |
| return fig |
|
|
|
|
| |
|
|
| app = Dash(__name__) |
| app.title = "Hypercube Visualization and Path Exploration Tool" |
|
|
| app.layout = html.Div( |
| style={"maxWidth": "1200px", "margin": "0 auto", "padding": "0px"}, |
| children=[ |
| html.H2("Hypercube Visualization and Path Exploration Tool"), |
| html.Div(id="stats", style={"opacity": 0.7, "marginBottom": "0px"}), |
|
|
| html.Div( |
| style={"display": "grid", "gridTemplateColumns": "1fr 1fr 1fr", "gap": "8px", "marginTop": "20px"}, |
| children=[ |
| html.Div([ |
| html.Label("Dimension d"), |
| dcc.Slider( |
| id="dim", |
| min=1, |
| max=12, |
| step=1, |
| value=DEFAULTS["dimension"], |
| marks=None, |
| tooltip={"always_visible": True}, |
| ), |
| ]), |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| html.Div([ |
| dcc.Checklist( |
| id="show_labels", |
| options=[ |
| {"label": " Show bit labels", "value": "bits"}, |
| {"label": " Show int labels", "value": "ints"}, |
| ], |
| value=[], |
| style={"marginTop": "0px"}, |
| ) |
| ]), |
| html.Div([ |
| dcc.Checklist( |
| id="mark_negations", |
| options=[{"label": " Mark negations", "value": "neg"}], |
| value=[], |
| style={"marginTop": "0px"}, |
| ) |
| ]), |
| html.Div(), |
| ], |
| ), |
|
|
|
|
| html.Div( |
| style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "8px"}, |
| children=[ |
| dcc.Input(id="manual_path", |
| placeholder="Enter path (e.g. 0,1,3)", |
| style={"flex": 1}, debounce=True), |
| html.Button("Set path", id="btn_set", n_clicks=0), |
| html.Button("Longest CIB", id="btn_longest_cib", n_clicks=0, |
| style={"background": "#059669", "color": "white"}), |
| html.Button("Shorter CIB", id="btn_shorter_cib", n_clicks=0, |
| style={"background": "#FFFF00"}), |
| html.Button("Clear", id="btn_clear", n_clicks=0), |
| ] |
| ), |
|
|
| |
| html.Div( |
| style={"display": "flex", "gap": "8px", "alignItems": "center", "marginBottom": "8px"}, |
| children=[ |
| html.Span("Swap dimensions:", style={"fontSize": "0.9rem"}), |
| dcc.Input( |
| id="swap_i", |
| type="number", |
| min=0, |
| step=1, |
| value=0, |
| placeholder="i", |
| style={"width": "60px"}, |
| ), |
| dcc.Input( |
| id="swap_j", |
| type="number", |
| min=0, |
| step=1, |
| value=0, |
| placeholder="j", |
| style={"width": "60px"}, |
| ), |
| html.Button("Swap", id="btn_swap", n_clicks=0), |
|
|
| html.Span("Flip dimension:", style={"fontSize": "0.9rem", "marginLeft": "16px"}), |
| dcc.Input( |
| id="flip_k", |
| type="number", |
| min=0, |
| step=1, |
| value=0, |
| placeholder="k", |
| style={"width": "60px"}, |
| ), |
| html.Button("Flip", id="btn_flip", n_clicks=0), |
| ], |
| ), |
|
|
|
|
| html.Div( |
| id="path_info", |
| style={ |
| "marginBottom": "8px", |
| "fontFamily": "monospace", |
| "whiteSpace": "normal", |
| "overflow": "visible", |
| "textOverflow": "clip", |
| "lineHeight": "1.3", |
| }, |
| ), |
|
|
|
|
| dcc.Graph(id="fig", style={"height": "800px"}, config={"displayModeBar": True}), |
| dcc.Store(id="path_store", data=[]), |
|
|
| html.Div( |
| id="path_bits", |
| style={ |
| "marginTop": "12px", |
| "fontFamily": "monospace", |
| "whiteSpace": "pre-wrap", |
| "fontSize": "12px", |
| }, |
| ), |
| ] |
| ) |
|
|
| @app.callback( |
| Output("stats", "children"), |
| Input("dim", "value"), |
| Input("path_store", "data"), |
| ) |
| def stats(d, path): |
| n = 1 << d |
| m = d * (1 << (d - 1)) |
| plen = len(path) if path else 0 |
| return f"Q_{d} Β· vertices: {n} Β· edges: {m} Β· path length: {max(0, plen - 1)}" |
|
|
| @app.callback( |
| Output("path_store", "data"), |
| Input("fig", "clickData"), |
| Input("btn_clear", "n_clicks"), |
| Input("btn_longest_cib", "n_clicks"), |
| Input("btn_shorter_cib", "n_clicks"), |
| Input("btn_set", "n_clicks"), |
| Input("btn_swap", "n_clicks"), |
| Input("btn_flip", "n_clicks"), |
| State("path_store", "data"), |
| State("manual_path", "value"), |
| State("dim", "value"), |
| State("swap_i", "value"), |
| State("swap_j", "value"), |
| State("flip_k", "value"), |
| prevent_initial_call=True |
| ) |
| def update_path(clickData, |
| n_clear, |
| n_longest, |
| n_shorter, |
| n_set, |
| n_swap, |
| n_flip, |
| path, |
| manual_text, |
| d, |
| swap_i, |
| swap_j, |
| flip_k): |
| trigger = ctx.triggered_id |
| path = path or [] |
| d = int(d) |
|
|
|
|
| |
| if trigger == "btn_clear": |
| return [] |
|
|
| |
| if trigger == "btn_longest_cib": |
| return longest_cib(d) |
|
|
| |
| if trigger == "btn_shorter_cib": |
| return shorter_cib(d) |
|
|
| |
| if trigger == "btn_set": |
| newp = parse_path(manual_text or "", d) |
| return newp if newp else path |
|
|
| |
| if trigger == "btn_swap": |
| try: |
| i = int(swap_i) if swap_i is not None else None |
| j = int(swap_j) if swap_j is not None else None |
| except (TypeError, ValueError): |
| return path |
| if i is None or j is None: |
| return path |
| |
| if not (0 <= i < d and 0 <= j < d): |
| return path |
| return swap_dims_path(path, d, i, j) |
|
|
| |
| if trigger == "btn_flip": |
| try: |
| k = int(flip_k) if flip_k is not None else None |
| except (TypeError, ValueError): |
| return path |
| if k is None or not (0 <= k < d): |
| return path |
| return flip_dim_path(path, d, k) |
|
|
|
|
| |
| if trigger == "fig" and clickData and clickData.get("points"): |
| p = clickData["points"][0] |
| cd = p.get("customdata") |
|
|
| |
| if isinstance(cd, (int, float)): |
| vid = int(cd) |
|
|
| |
| if not path: |
| return [vid] |
|
|
| |
| if vid == path[-1]: |
| return path[:-1] |
|
|
| |
| if len(path) >= 2 and vid == path[-2]: |
| return path[:-1] |
|
|
| |
| if hamming_dist(vid, path[-1]) == 1: |
| return path + [vid] |
|
|
| |
| return [vid] |
|
|
|
|
| |
| if isinstance(cd, (list, tuple)) and len(cd) == 2: |
| u, v = int(cd[0]), int(cd[1]) |
| if not path: |
| return [u, v] |
| last = path[-1] |
| if last == u: |
| return path + [v] |
| if last == v: |
| return path + [u] |
| return [u, v] |
|
|
| return path |
|
|
| @app.callback( |
| Output("fig", "figure"), |
| Input("dim", "value"), |
| Input("show_labels", "value"), |
| Input("path_store", "data"), |
| Input("mark_negations", "value"), |
| ) |
| def render(d, show_labels_vals, path, mark_vals): |
| labels_vals = show_labels_vals or [] |
| show_bits = "bits" in labels_vals |
| show_ints = "ints" in labels_vals |
|
|
| mark_vals = mark_vals or [] |
| mark_negations = "neg" in mark_vals |
|
|
| fig = make_figure( |
| d=int(d), |
| show_bits=show_bits, |
| show_ints=show_ints, |
| mark_negations=mark_negations, |
| node_r=DEFAULTS["node_radius"], |
| edge_w=DEFAULTS["edge_width"], |
| path=path or [], |
| scale_base=float(DEFAULTS["scale"]), |
| ) |
| return fig |
|
|
|
|
|
|
|
|
| @app.callback( |
| Output("path_info", "children"), |
| Input("dim", "value"), |
| Input("path_store", "data"), |
| ) |
| def path_info(d, path): |
| path = path or [] |
|
|
| if not path: |
| return html.Span("Path: (empty)") |
|
|
| path_str = ", ".join(str(v) for v in path) |
|
|
| label, valid = classify_path(path, d) |
|
|
| color = { |
| "snake": "green", |
| "coil": "green", |
| "almost coil": "green", |
| "not snake": "red", |
| }[label] |
|
|
| return html.Span([ |
| html.Span(f"Path: {path_str} "), |
| html.Span(f"[{label}]", style={"color": color, "fontWeight": "bold"}), |
| ]) |
|
|
|
|
| @app.callback( |
| Output("path_bits", "children"), |
| Input("dim", "value"), |
| Input("path_store", "data"), |
| Input("show_labels", "value"), |
| ) |
| def path_bits_view(d, path, show_labels_vals): |
| path = path or [] |
| labels_vals = show_labels_vals or [] |
|
|
| |
| if not path or "bits" not in labels_vals: |
| return "" |
|
|
| d = int(d) |
| lines = [] |
| for v in path: |
| b = int_to_bin(v, d) |
| ones = b.count("1") |
| lines.append(f"{b} ({ones})") |
|
|
| |
| return html.Pre( |
| "\n".join(lines), |
| style={"margin": 0}, |
| ) |
|
|
|
|
| |
| if __name__ == "__main__": |
| import os |
| port = int(os.environ.get("PORT", "7860")) |
| app.run(host="0.0.0.0", port=port, debug=False) |
|
|
|
|