Add interactive visualization for bounded-pruned Yen + A*, forward pruning, and Dijkstra vs A* - #4
Merged
Conversation
…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).
…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).
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-yenandalldirectedpaths-source-sandwich-prunebranches:BoundedPrunedYenKShortestPath+AStarSpurEngineAllDirectedPathsbaseline (backward BFS only)AllDirectedPaths+ forward-BFS sandwich gateThe 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:
1,1— lower bound isprefixCost (3) + h[spurNode] = 7; bounded driver ran it because no cheaper candidate is known yet.").Live
.nojekyll)cd visualizations && docker build -t jgrapht-viz . && docker run --rm -p 8080:8080 jgrapht-viz→ http://localhost:8080http.server, andvercel deploy --prodconfigs included.What's in the diff
Self-contained — no build step, no external CSS/JS, no runtime dependencies.
Numerical wins surfaced in the viz
Grid 6×5, K=3 yen:
Path-chain K=2 yen (impossible-spur pathology):
Layered DAG, budget=3 forward pruning (12 nodes, 18 edges):
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
<details>.@media (max-width: 980px)stacks panes vertically; canvas shrinks to 230 px./favicon.icoand/.🤖 Generated with Claude Code