Skip to content

Add interactive visualization for bounded-pruned Yen + A*, forward pruning, and Dijkstra vs A* - #4

Merged
seilat merged 12 commits into
masterfrom
visualizations
May 16, 2026
Merged

Add interactive visualization for bounded-pruned Yen + A*, forward pruning, and Dijkstra vs A*#4
seilat merged 12 commits into
masterfrom
visualizations

Conversation

@seilat

@seilat seilat commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a self-contained interactive web visualization that animates and compares three pairs of shortest-path algorithms side by side, mirroring the implementations on the bounded-pruned-yen and alldirectedpaths-source-sandwich-prune branches:

Mode Pane A Pane B
K-shortest paths (Yen) Vanilla Yen + Dijkstra BoundedPrunedYenKShortestPath + AStarSpurEngine
Single shortest path Standard Dijkstra A* with reverse-Dijkstra heuristic
All paths — forward pruning AllDirectedPaths baseline (backward BFS only) AllDirectedPaths + forward-BFS sandwich gate

The page reproduces the exact algorithms — admissible reverse-Dijkstra heuristic, spur-task lower-bound queue, impossible-spur skip, forward-BFS-gated edge decoration — so the counters mean the same thing the JMH benches do.

Highlights for reviewers:

  • Step counters per pane (spur tasks, node expansions, edges retained/dropped, etc.).
  • Per-event explanation under each canvas: every step says why the algorithm just did what it did (e.g. "Spur task starts at 1,1 — lower bound is prefixCost (3) + h[spurNode] = 7; bounded driver ran it because no cheaper candidate is known yet.").
  • Glossary cards (expansion, relaxation, heuristic, admissibility, spur, forward-pruning gate, …).
  • Mode-aware narrative explainer (collapsible).
  • Stereo-style transport controls: load (⏏), prev (⏮), play (▶), pause (❚❚), next (⏭), stop (⏹).
  • Step-Back support implemented as reset + replay to (step − 1), cheap for the event counts these algorithms produce.
  • Gold persistent highlight for accepted paths; muted-green scaffolding for the decoration map so the answer stands out.
  • Forward-pruning mode: pane A is gated during pane B's forward-BFS prelude so the "sandwich precompute happens first, then both backward BFSs start together" timing is preserved on the timeline — pane B finishes before pane A in the visualization, matching the runtime story.

Live

What's in the diff

.nojekyll                           |    0   (bypasses upstream Jekyll docs/ site on Pages)
index.html                          |   12   (root redirect → /visualizations/)
visualizations/.dockerignore        |    4
visualizations/Dockerfile           |   23   (nginx-alpine, non-root, port 8080, healthcheck)
visualizations/README.md            |  123
visualizations/apple-touch-icon.png |  Bin   (copied from docs/img/)
visualizations/docker-compose.yml   |   20   (read-only rootfs, no-new-privileges)
visualizations/favicon.ico          |  Bin   (copied from docs/img/)
visualizations/index.html           | 2112   (the whole visualization — inline HTML/CSS/JS, zero deps)
visualizations/nginx.conf           |   20   (one location block, no autoindex/server tokens)
visualizations/vercel.json          |   14   (basic hardening headers)

Self-contained — no build step, no external CSS/JS, no runtime dependencies.

Numerical wins surfaced in the viz

Grid 6×5, K=3 yen:

Variant Spur tasks ran Node expansions
Vanilla Yen + Dijkstra 18 280
Bounded-pruned Yen + A* 9 141

Path-chain K=2 yen (impossible-spur pathology):

Variant Spur searches ran Tasks pruned/skipped
Vanilla 8 (all return null) 0
Bounded-pruned 0 8

Layered DAG, budget=3 forward pruning (12 nodes, 18 edges):

Variant Retained Considered Vertices marked Dropped
Baseline (backward BFS only) 16 16 11 (backward) 0
Forward pruning 13 14 9 (forward) 1

In addition to the dropped edge, the orphan chain past the dropped edge is never queued — so total work is strictly less than the considered counter suggests.

Test plan

  • All three modes load and run end-to-end on every preset graph.
  • Yen K=1..5, single shortest, forward-pruning budgets 2..6 all play without console errors (verified in Chrome via the live Pages deployment).
  • Algorithm correctness: both panes find the same set of optimal paths in every mode and every example (where K≥2 alternates differ they tie on cost — Yen tie-breaking, not a bug).
  • Step Back replays correctly to (step − 1) including the forward-pruning prelude offset.
  • Stereo transport buttons (Load auto-plays, Pause/Play, Prev/Next, Stop) all behave as labelled.
  • At 100% browser zoom on a 1366×768 viewport the panes + paths-found + legend all fit above the fold; glossary and narrative explainers are below as collapsible <details>.
  • @media (max-width: 980px) stacks panes vertically; canvas shrinks to 230 px.
  • Docker image builds (~20 MB) and serves 200 OK at /favicon.ico and /.
  • GitHub Pages live URL serves the latest commit (verified via curl).

🤖 Generated with Claude Code

seilat added 9 commits May 12, 2026 13:52
…d Yen + A*, A* vs Dijkstra, and AllDirectedPaths sandwich prune

Single self-contained HTML page (no external JS/CSS) that animates and
compares three pairs of shortest-path algorithms side by side:

  1. Yen K-shortest paths:
     - left:  vanilla Yen + Dijkstra spur
     - right: BoundedPrunedYenKShortestPath + AStarSpurEngine
  2. Single shortest path:
     - left:  standard Dijkstra
     - right: A* with reverse-Dijkstra heuristic
  3. AllDirectedPaths edge decoration:
     - left:  baseline backward BFS only
     - right: forward+backward sandwich prune

The page reproduces the exact algorithms implemented in JGraphT on the
bounded-pruned-yen and alldirectedpaths-source-sandwich-prune branches:
admissible reverse-Dijkstra heuristic, spur-task lower-bound queue,
impossible-spur skip, and forward-BFS-gated edge decoration.

Step counters, per-event explanations, and a glossary of expansion,
relaxation, heuristic, and admissibility terms accompany each animation.

Ships with:
  - Dockerfile (nginx-alpine, non-root, port 8080, healthcheck)
  - docker-compose.yml (read-only rootfs, tmpfs for cache, no-new-privileges)
  - nginx.conf (one location block, no autoindex, no server tokens)
  - vercel.json (static deploy config with basic hardening headers)
  - README.md (run instructions for direct/Python/Docker/Vercel)
The fork inherits a Jekyll docs/ site from upstream master, which makes
the GitHub Pages build hang trying to process it. Drop a .nojekyll
sentinel at the root so Pages serves files verbatim, and add a tiny
index.html at the root that redirects to /visualizations/.
After both panes finish edge-decoration, run a simple-path DFS over the
retained edges (mirrors the second step of AllDirectedPaths.getAllPaths)
and emit one accept event per path found. The paths now show up in the
"Paths found" section, matching the behaviour of yen and single modes.

Also gate the accept-event reset of explored/banned scratch state to yen
mode only — sandwich's forward/backward marks and kept/dropped edge
classification must survive into the path-listing phase, and single
mode's expansion frontier already needs to stay visible.
…tat alignment

- Add "◀ Step" button alongside "Step ▶". Implemented as reset + replay
  to (step - 1) so the event-application path stays single-sourced; cheap
  for the event counts these algorithms produce (<1500 on the largest).
- Add five glossary cards for the sandwich-prune concepts that were
  missing: forward BFS / dF(v), backward BFS / dB(v), decoration map,
  orphan branch, sandwich condition.
- Add a larger sandwich example "Highway + orphan chain + dead ends"
  (12 nodes, 35 steps). Demonstrates both kinds of savings on one graph:
  an orphan chain X→Y→Z→W→T that the sandwich gate drops at W→T (so the
  rest of the chain is never even visited), plus a forward-only dead-end
  A→P→Q that shows up in the cyan forward halo but nowhere else.
- Add the forward-marked halo + sandwich-mode edge classifications to the
  legend, with explicit "banned (Yen) / dropped (sandwich)" wording so
  the shared red colour isn't ambiguous.
- Fix stat-cell alignment when a label wraps to two lines ("vertices
  forward-marked" / "vertices backward-marked"): switch .stat to a
  flex column, set min-height on .stat, and reserve two lines of vertical
  space on the label so all four numbers stay on the same baseline.
…ansport, sandwich polish

Visual hierarchy reworked:

- Accepted paths now persist as gold (--edge-accepted #ffd166) across
  every accept event, not just the last one. Fixes the bug where the
  prior accepted path (e.g. "D → C → T") faded out and only the final
  one was visible; gold edges now accumulate on every accept and stay
  visible after the brief green pulse.
- Sandwich-kept edges are now a muted green (--edge-kept #4f7a59) so
  the gold accepted paths can stand out on top, instead of the bright
  green that competed with the accepted-path highlight.
- Sandwich-dropped edges use a darker red (--edge-dropped) and a dashed
  stroke, distinguishing them from the solid red Yen-banned edges.
- The currently-considered edge (sandwich's edgeConsider event) is now
  yellow (--edge-considered), brightest among edges to draw the eye.
- Edge weight labels are now hidden in sandwich mode (AllDirectedPaths is
  unweighted) and in any other graph whose every edge has weight 1, so
  the visualization isn't littered with redundant "1" labels.

Impossible-spur cue:

- When a spurSkip event reports an impossible-spur reason, the spur node
  gets a thick red "strike-through" (X) drawn over it. Reset on the
  next spurStart. Added an entry to the legend so the cue is
  self-documenting.

Transport bar:

- Buttons reorganised stereo-style — load (eject), prev (⏮), play (▶),
  pause (❚❚), next (⏭), stop (⏹) — grouped into a single .transport
  span with shared border and no per-button radius.

New larger sandwich example:

- "Layered DAG" — 12 nodes, 18 edges, budget 3 — produces 56 steps and
  7 simple s→t paths in both panes. The orphan chain M1→O1→O2→T is
  dropped at the gate (the sandwich gate cuts O2→T immediately, so the
  rest is never queued) and the source-only dead-end S→a→DE shows up
  as a cyan halo with no kept edges, demonstrating both kinds of
  forward/backward asymmetry on one graph.
…ct layout, prelude offset

Rename (maintainer preference):
- Mode value sandwich → forwardPruning everywhere it's an identifier
  (mode option, schema/event-info/example keys, internal variable names).
- UI labels: "sandwich prune" → "forward pruning"; algorithm description
  in event explanations and narrative headings updated to match.
- The technical term "sandwich condition" survives as a parenthetical
  in the glossary card, since the algorithm literature uses it.
- runWithSandwich/runNoSandwich → runForwardPruning/runBaselineDecoration.
- sandwichSummary event kind → forwardPruningSummary; narrativeSandwich
  DOM id → narrativeForwardPruning.

Forward-BFS prelude offset:
- In forward-pruning mode pane A is now gated during pane B's forward-BFS
  prelude so the viewer sees the precompute happen first. Once pane B
  reaches the second "phase" event (start of backward BFS), pane A's
  backward BFS catches up and the two run in lockstep. Implemented via
  state.preludeB (index of the second phase event in pane B); applyOne
  skips pane A while state.iB < state.preludeB.
- The visible effect: pane B starts the forward BFS marking nodes with
  cyan halos while pane A sits idle, then both panes' backward BFS
  begins together — making the win visible on the timeline (pane B
  finishes earlier, after fewer additional steps).

100%-zoom compact layout:
- Canvas height 360 → 270px; pane min-height 540 → 420px; tighter pane
  paddings; smaller stat-cell padding and font sizes (no information
  lost — values and labels just take less vertical space).
- Narrative and glossary are now wrapped in <details> elements that
  collapse to a single summary line by default, so the panes + stats +
  paths-found + legend all fit above the fold on a typical 720+ px
  laptop viewport at 100% zoom.
- Section order rearranged: panes → paths found → legend → collapsible
  glossary → collapsible narrative. Paths are visible without scrolling.

Narrow / mobile (initial):
- @media (max-width: 980px) stacks the two panes vertically (single
  column) and shrinks the canvas to 230px. Header sizes also shrink.
  Vertical transport bar for true mobile remains a future task.
…n variants

Adds the five K-shortest variants the JGraphT JMH grid now benches:

- Yen + Dijkstra  (classical YenKShortestPath, default left pane)
- Yen + A*        (BoundedPrunedYen + AStarSpurEngine + setBoundedPruning(false))
- BPYen + Dijkstra (bounded prune + DijkstraSpurEngine — isolates the
                    bounded layer from the A* engine)
- BPYen + A*      (BoundedPrunedYen + AStarSpurEngine, default right pane)
- Eppstein        (k lowest-cost walks; loops allowed)

Implementation:

- The four Yen-family variants share a single parametric driver
  runYen(g, K, { engine, bounded }). Existing runVanillaYen and
  runBoundedPrunedYen become thin wrappers; runYenAStarUnbounded and
  runBoundedPrunedYenDijkstra fill the new corners.
- Eppstein is implemented in spirit rather than via the persistent
  heap-on-heap data structure: reverse Dijkstra from the target builds
  the shortest-path tree, every non-tree edge carries the sidetrack
  cost δ(u,v) = w(u,v) + dB(v) − dB(u), then k lowest-cost walks are
  enumerated via best-first search. Returns the same k walks Eppstein
  would on the small example graphs.

UI:

- Two new selectors in the controls bar — "A" and "B" — pick the
  algorithm rendered in each pane. Visible only in K-shortest mode.
  Each pane's H2 and badge update from a YEN_ALGO_INFO registry so the
  user sees which algorithm the counter numbers came from.
- Change listeners on both selectors trigger reload, like the existing
  example/K/budget/mode selectors.

Verified end-to-end in Chrome on grid K=3:
  yen-dijkstra : 18 spurs / 280 expansions
  yen-astar    : 18 spurs / 146 expansions   (A* alone, ~1.9x)
  bp-dijkstra  : 17 spurs / 295 expansions   (bounded layer alone, ~no win)
  bp-astar     : 9  spurs / 141 expansions   (both)
  eppstein     : returns walks (cost 5, 7, 7 on Wiki — one with E→D→E loop)

The numbers reproduce the JMH headline finding: the A* spur engine does
essentially all the speedup work; the bounded layer adds a few percent
on top of A* and can slightly regress when paired with Dijkstra.

Narrative updated to introduce all five variants and suggest comparisons
(both panes A*-engined to see the marginal bounded-layer win; one pane
Eppstein vs one pane Yen-family to see walks-vs-simple-paths).
@seilat
seilat temporarily deployed to github-pages May 15, 2026 15:47 — with GitHub Pages Inactive
…T*, sidetracks, walk extraction

Previously the Eppstein driver collapsed all preprocessing into zero
events: it computed dB from a single reverseDistances call and emitted
just ~K+5 events total (init, two info, K accepts, end). That made
Eppstein look near-instant relative to Yen-family variants and obscured
where the algorithm actually spends time.

The new runEppstein emits four animated phases:

  1. Reverse Dijkstra from the target — one vertexBwd event per pop,
     same renderer as forward-pruning's backward BFS (green-tints each
     settled node with its dB value).
  2. Shortest-path tree T* — one treeEdge event per non-target vertex's
     chosen out-edge. Rendered as muted gold (--edge-tree) so the user
     can see the tree structure emerge edge by edge.
  3. Sidetrack labelling — one sidetrack event per edge, carrying its
     δ(u,v) = w(u,v) + dB(v) − dB(u). The renderer now shows the δ value
     next to each edge (tree edges show δ=0 in the default grey label
     colour; non-tree edges show δ in the dedicated sidetrack colour).
  4. Walk extraction — best-first search over walks; one walkPop event
     per priority-queue pop, followed by the existing accept events.

Per-event explanations and legend entries added for treeEdge, sidetrack,
and walkPop. Eppstein on Wiki K=3 now produces 73 steps (vs 32 for
BPYen+A*), matching the runtime story: Eppstein pays an up-front cost
that's flat in k, while Yen scales with k.

Two small renderer fixes needed for Eppstein to coexist with the rest:

- The accept and end handlers in yen mode used to clear exploredNodes
  (per-spur scratch). For Eppstein those nodes are the reverse-Dijkstra
  settled set — meaningful as a final state. Both handlers now skip the
  clear when treeEdges.size > 0 (the Eppstein flag).
- Eppstein no longer emits a synthetic "initial" event; that event's
  handler resets exploredNodes via new Set(ev.expansions), which would
  immediately wipe the rev-Dijk greens we just rendered.

Two new CSS tokens — --edge-tree (#8a7a3a, muted gold for T*) and
--edge-sidetrack (#6a90c0, label colour for non-tree edges).
@seilat
seilat temporarily deployed to github-pages May 15, 2026 18:30 — with GitHub Pages Inactive
…ffset

Two issues from running Eppstein in K-shortest mode:

(1) Stats stuck at 0/0/0/N. The Yen-family slot labels — spur tasks,
    node expansions, candidates, accepted paths — don't apply to Eppstein.
    Its meaningful counters are vertices settled by reverse Dijkstra,
    tree edges in T*, sidetracks labelled, and walks extracted. Now:
    - PaneState tracks a per-pane this.algo set in loadAndRun.
    - YEN_ALGO_INFO entry for Eppstein carries its own four labels.
    - updateSlots() dispatches on this.algo when mode === "yen":
        Eppstein   → exploredNodes / treeEdges / sidetrackLabels / accept
        otherwise  → spurCount / expansionCount / cand|prune / accept
    - Slot labels are overwritten in loadAndRun from each pane's algo.

(2) 1669 steps on grid was too noisy. Two causes:
    - walkPop event was emitted per priority-queue pop in the walk-
      extraction phase. On a 30-node grid that's ~1500 pops. Now we
      skip walkPop entirely and let the K accept events plus a closing
      info ("N pops collapsed into K accepts") tell the story.
    - The OTHER pane started its k-shortest search at step 1, while
      Eppstein was still doing preprocessing. That made the user wait
      through 161 unrelated steps to see pane B's first spur. Now:
        applyOne() supports per-pane prelude offsets. Each pane has a
        state.preludeA / state.preludeB, computed via computePrelude().
        For Eppstein the prelude ends at the 4th "phase" event (start
        of walk extraction); for forwardPruning's pane B it ends at the
        2nd phase event. The other pane waits while the prelude runs.
        Generalises the original sandwich-mode offset.

Grid K=3, Eppstein vs BPYen+A* (verified in Chrome):
  preludeA = 161    Eppstein preprocessing
  preludeB = 0      BPYen+A* has no prelude
  totalSteps = 342  (was 1669 before this change)
  Aslots = 30 / 29 / 98 / 3  (settled / tree / sidetracks / walks)
  Bslots = 17 / 141 / 1 / 3  (spurs / expansions / pruned / accepts)

Both panes find 3 paths. Wiki K=3 produces 73 steps, also as before.
@seilat
seilat merged commit 21378e9 into master May 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant