diff --git a/.gitignore b/.gitignore index 2fe70bf..defc9ec 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,7 @@ sample-data/*.json # Keep repository sample graphs !sample-data/example.json !sample-data/rg_imaging.json +!sample-data/synthetic.json # PyInstaller output, to be enabled later diff --git a/CHANGELOG.md b/CHANGELOG.md index 868becd..4c7b789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,63 @@ All notable changes to LTSVisualizer are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/dbera/LTSVisualizer) +## [Unreleased](https://github.com/dbera/LTSVisualizer/compare/v0.4.0...HEAD) -## [0.4.0](https://github.com/dbera/LTSVisualizer) - 2026-08-06 +### Added + +- Added an on-demand **Analysis** tab alongside the Inspector. +- Added terminal-state detection for states without outgoing transitions. +- Added iterative strongly connected component computation without recursive graph traversal. +- Added cyclic-component classification for multi-state SCCs and singleton states with self-loops. +- Added inline Web Worker execution so graph analysis does not block the main browser interface. +- Added analysis cancellation, stale-result protection, worker error handling, rerun support, and automatic reset when another graph is loaded. +- Added terminal-state filtering, pagination with at most 100 results per page, and state navigation using the current neighborhood depth. +- Added cyclic-component minimum-size filtering, pagination with at most 100 results per page, and component-only graph views. +- Added user-facing sequential cyclic-component numbering independent of internal SCC identifiers. +- Added large synthetic tests for long acyclic graphs and large strongly connected components. +- Added worker-controller tests for success, failure, cancellation, reruns, stale responses, malformed responses, reset, and disposal. +- Added `sample-data/synthetic.json` with known terminal-state and SCC results for manual verification. +- Added a **Paths** tab for a user-defined number of shortest paths between source and target states. +- Added configurable maximum visits per state; `1` produces loopless paths and higher values permit bounded revisits. +- Added equal-source-and-target support, including zero-transition paths and bounded returning cycles. +- Added edge-ID-sequence uniqueness so parallel transitions produce distinct alternatives. +- Added deterministic shortest-first ordering by transition count. +- Added reverse-distance-guided search to prioritize reachable alternatives and prune states that cannot reach the target. +- Added internal candidate safeguards with partial-result reporting when additional paths may exist. +- Added an inline path-search Web Worker with cancellation, stale-response protection, reruns, errors, reset, and disposal. +- Added path-search and worker-controller tests covering ordering, revisits, self-loops, parallel edges, equal endpoints, safeguards, cancellation, long paths, reverse-distance pruning, and lifecycle behavior. +- Added computed-path navigation using existing visualization and JSON and PlantUML exports. +- Added expandable transition details with names, source and target states, and exact edge IDs. +- Added clickable result details that center and select graph edges or states while keeping the Paths tab open. +- Added graph-view snapshots and **Return to graph view** restoration for visible elements, positions, zoom, pan, focus, neighborhood depth, and layout. +- Added separate curved rendering for parallel transitions. + +### Changed + +- Kept graph analysis explicitly user-triggered instead of running automatically when a graph is loaded or when the Analysis tab is opened. +- Limited worker input to node IDs and edge topology instead of transferring markings, labels, inputs, outputs, or other semantic data. +- Kept terminal-state terminology separate from deadlock classification because successful completion and unintended deadlock cannot be distinguished from topology alone. +- Updated path selection to leave a component-only graph view before path construction begins. +- Preserved analysis results while switching between Inspector and Analysis tabs or navigating individual results. +- Preserved path-search results while switching among Inspector, Analysis, and Paths. +- Kept computed-path inspection non-disruptive by pinning Inspector data without switching tabs automatically. +- Reused the selected-path representation so computed paths retain ordered edge IDs, loops, repeated traversals, parallel-edge identity, semantic data, and export behavior. +- Kept computed-path viewport fitting separate from layout so selecting a result does not rearrange states. +- Reserved the path-search model for future ordered transition constraints and partial transition-data matching. + +### Fixed + +- Fixed selected SCC views retaining states and transitions from the previously visible graph. +- Fixed cyclic components being displayed with confusing internal SCC IDs instead of sequential user-facing numbers. +- Fixed stale worker responses being able to affect a newer analysis request. +- Fixed worker cleanup during cancellation, reset, rerun, and component unmounting. +- Fixed dense searches wasting candidate capacity on branches that cannot reach the target by adding reverse-distance pruning. +- Fixed long-path candidate construction repeatedly copying complete path data by using parent-linked candidates and reconstructing edge IDs only for completed paths. +- Fixed computed paths being difficult to locate by fitting the viewport without changing node positions. +- Fixed leaving a computed-path view requiring manual graph reconstruction by restoring the saved graph view explicitly. +- Fixed edge-unique paths with identical state sequences being visually indistinguishable by separating parallel curves and exposing exact transition details. + +## [0.4.0](https://github.com/dbera/LTSVisualizer/compare/v0.3.0...v0.4.0) - 2026-08-06 ### Added @@ -221,7 +275,7 @@ When preparing a release: Example: ```markdown -## [0.4.0] - 2026-08-06 +## [0.5.0] - YYYY-MM-DD ``` ## Versioning guidance diff --git a/README.md b/README.md index b86a33d..2dc70dc 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,48 @@ LTSVisualizer does not import PlantUML files. PlantUML remains available only as - Pin inspector content while continuing to explore the graph. - Clear pinned inspector content without clearing a selected path. +### Graph analysis + +- Open the **Analysis** tab without affecting the Inspector. +- Run analysis explicitly with **Run analysis**. Analysis is never started automatically when a graph is loaded. +- Detect terminal states, defined as states with no outgoing transitions. +- Compute strongly connected components using an iterative graph traversal that avoids recursive call-stack limits. +- Classify an SCC as cyclic when it contains more than one state or when a singleton state has a self-loop. +- Run graph analysis in an inline Web Worker so the browser interface remains responsive. +- Cancel an analysis while it is running. +- Ignore stale worker results after cancellation, reruns, or loading another graph. +- Filter terminal states by state ID and browse large result sets in pages of 100. +- Select a terminal state to open its current neighborhood view. +- Filter cyclic components by minimum component size and browse results in pages of 100. +- Select one cyclic component to display only its member states and internal transitions. +- Clear a component-only view and return to normal neighborhood exploration. +- Use the same analysis functionality in the hosted application and the offline `file:///` build. + +A terminal state is not automatically an error. Whether a terminal state represents successful completion or an unintended deadlock depends on the model. LTSVisualizer reports terminal states and does not attempt to classify their business meaning. + +Analysis results are held in browser memory for the currently loaded graph. They are reset when another graph is opened and are not added to graph or selected-path exports. + +### Bounded alternative path search + +- Open the **Paths** tab to compute up to a user-defined number of shortest paths between two states. +- Set **Visits per state** to `1` for loopless paths or to a higher value to allow bounded revisits. +- Support equal source and target states. The zero-transition path is returned first, and returning cycles may follow when the visit bound permits them. +- Treat paths as unique by ordered edge-ID sequence, so parallel transitions remain distinct even when they connect the same states. +- Order results by increasing transition count, with deterministic ordering for equal-length alternatives. +- Use reverse shortest-distance guidance from the target to prioritize reachable alternatives and prune states that cannot reach the target. +- Run searches in an inline Web Worker with cancellation, stale-result protection, errors, reruns, and reset when another graph is loaded. +- Stop safely at internal candidate safeguards and report partial results without claiming that no additional paths exist. +- Select a result to reuse existing path visualization and JSON and PlantUML exports. +- Separate parallel transitions visually and expose transition names, source and target states, and exact edge IDs. +- Select transition or state details to center and select the corresponding graph element without leaving the **Paths** tab. +- Pin clicked transition or state data for later viewing in Inspector without switching tabs automatically. +- Preserve search results while switching among Inspector, Analysis, and Paths. +- Fit the viewport around a computed path without changing node positions. +- Use **Return to graph view** to restore visible elements, positions, zoom, pan, focus, neighborhood depth, and layout while retaining results. +- Use the same functionality in hosted and offline `file:///` builds. + +Path-search results are kept in browser memory for the currently loaded graph and are reset when another graph is opened. + ### Manual path selection - Start a path from the currently focused state. @@ -105,6 +147,19 @@ The repository includes: - `sample-data/example.json`: a small graph for quick checks. - `sample-data/rg_imaging.json`: a larger, realistic reachability graph. +- `sample-data/synthetic.json`: a synthetic graph for terminal-state and strongly connected component analysis. + +The expected analysis for `synthetic.json` is: + +```text +States: 32 +Transitions: 41 +Terminal states: 4 +Cyclic components: 5 +States in cyclic components: 19 +Largest cyclic component: 8 +Cyclic component sizes: 8, 5, 3, 2, 1 +``` ## JSON input format @@ -337,6 +392,34 @@ Graph data remains in the browser and is not uploaded to a server. For very large graphs, neighborhood exploration is recommended instead of displaying every state and transition simultaneously. +### Analyze a graph + +1. Open the **Analysis** tab in the right-hand panel. +2. Select **Run analysis**. Loading a graph or opening the tab does not start computation. +3. Select **Cancel** if the analysis should be stopped. +4. Review the terminal-state and cyclic-component summary. +5. Expand **Terminal states** to filter and select a terminal state. +6. Expand **Cyclic components** to filter by minimum size and select a component. +7. Select **Clear component view** to return to normal neighborhood exploration. +8. Select **Run again** to recompute the results for the current graph. + +For large graphs, worker execution prevents the analysis algorithm from blocking the main browser interface. Preparing and transferring graph topology still consumes browser memory, so analysis remains an explicit user action. + +### Find alternative paths + +1. Open the **Paths** tab. +2. Enter source and target state IDs. +3. Choose the requested number of paths. +4. Set **Visits per state** to `1` for loopless paths or higher for bounded revisits. +5. Select **Find paths**. During a running search, the action changes to **Cancel**. +6. Select a result to display it without relaying out its states. +7. Expand **Show transition details** to compare transition names, state pairs, and edge IDs. +8. Select a transition name or edge ID to center and select its edge, or select a state ID to center and select its state. +9. Use **Export .puml** or **Export .json** to export the displayed computed path. +10. Select **Return to graph view** to restore the prior graph context without clearing results. + +Paths are ordered by transition count and are unique by ordered edge IDs. If source and target are equal, the zero-transition path is valid. + ### Select a path 1. Search for or focus the desired starting state. @@ -438,6 +521,10 @@ React and TypeScript application |-- Cytoscape.js visualization |-- Search and neighborhood exploration |-- Structured semantic-data inspection + |-- On-demand terminal-state and SCC analysis + | `-- Inline Web Worker with cancellation + |-- Bounded alternative path search + | `-- Reverse-distance-guided inline Web Worker with cancellation |-- Manual path selection |-- Complete-graph JSON export |-- Selected-path JSON export @@ -480,6 +567,7 @@ LTSVisualizer/ | |-- src/ | | |-- components/ | | |-- graph/ +| | |-- workers/ | | |-- App.css | | |-- App.tsx | | |-- index.css @@ -491,7 +579,8 @@ LTSVisualizer/ | `-- vite.offline.config.ts |-- sample-data/ | |-- example.json -| `-- rg_imaging.json +| |-- rg_imaging.json +| `-- synthetic.json |-- CHANGELOG.md |-- CONTRIBUTING.md |-- LICENSE @@ -543,7 +632,7 @@ npm run build npm run build:offline ``` -The current test suite covers JSON validation and round trips, graph serialization, complete-graph export, path selection, loops, repeated states, parallel edges, selected-path export, semantic data, and PlantUML path export. +The current test suite covers JSON validation and round trips, graph serialization, complete-graph export, manual and computed path selection, loops, repeated states, bounded revisits, source-equals-target paths, self-loops, parallel edges, deterministic shortest-first ordering, reverse-distance pruning, resource safeguards, selected-path export, semantic data, PlantUML path export, terminal-state detection, iterative SCC computation, large synthetic graph topologies, and worker-controller lifecycle behavior. ## Build targets @@ -633,30 +722,28 @@ The tag triggers the offline HTML release workflow and publishes `LTSVisualizer. - PlantUML is an export-only format. - Extremely large full-graph views can be visually dense even when rendering remains responsive. - Global force-directed layouts are intentionally avoided because they can be computationally expensive in the browser. +- Terminal states are reported topologically and are not classified as successful completions or definite deadlocks. +- Graph analysis uses a worker and is user-triggered, but very large graphs still require additional browser memory for topology transfer and analysis results. +- Bounded path search is user-triggered and uses a worker, but highly connected graphs can still reach internal candidate safeguards before every requested alternative is found. Partial results are reported and additional valid paths may exist. +- Cancelling path search terminates its worker immediately; partial paths found before cancellation are not retained. - The offline release depends on browser support for local `file:///` applications and file selection. - GitHub Pages availability depends on successful processing by GitHub's deployment service. ## Roadmap -Planned priorities: +Planned priority: -1. Refactor graph loading, shared graph types, Cytoscape integration, and visualization logic. -2. Experiment with constrained graph search. +1. Extend bounded alternative path search with transition constraints. -The constrained graph-search experiment may support: +The future constrained-search extension may support: -- Start and optional target states - Required transitions in order +- Required transitions matched by transition name and specific or partial structured input and output data - Forbidden transitions -- Maximum path length -- Shortest matching paths -- Loops and parallel transitions -- Reuse of the existing path visualization and export functionality +- Additional constraint combinations while preserving bounded revisits, parallel-edge identity, shortest-first results, and existing visualization and export functionality Additional potential improvements include: -- Deadlock-state detection -- Strongly connected component analysis - State-to-state marking differences - Token-journey visualization - Transition-frequency analytics diff --git a/frontend/src/App.css b/frontend/src/App.css index 3c9ce18..1257f6d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -347,3 +347,368 @@ button.active { flex-wrap: wrap; } } + +.side-panel-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + margin: -4px 0 18px; + padding: 4px; + border-radius: 9px; + background: #e2e8f0; +} + +.side-panel-tabs button { + border-color: transparent; + background: transparent; + font-size: 13px; +} + +.side-panel-tabs button.active { + border-color: #bfdbfe; + background: #ffffff; + color: #1d4ed8; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); +} + +.analysis-heading { + margin-bottom: 16px; +} + +.analysis-heading h2 { + margin: 4px 0 0; + font-size: 18px; + line-height: 1.3; +} + +.analysis-message, +.analysis-results { + color: #475569; +} + +.analysis-message > p:first-child, +.analysis-results > p:first-child { + margin-top: 0; +} + +.analysis-input-summary, +.analysis-summary { + display: grid; + gap: 8px; + margin: 16px 0; +} + +.analysis-input-summary > div, +.analysis-summary > div { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 9px 11px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.analysis-input-summary dt, +.analysis-summary dt { + font-size: 13px; + font-weight: 650; +} + +.analysis-input-summary dd, +.analysis-summary dd { + margin: 0; + color: #0f172a; + font-variant-numeric: tabular-nums; + font-weight: 800; +} + +.analysis-note { + margin: 14px 0; + color: #64748b; + font-size: 12px; + line-height: 1.5; +} + +.analysis-action { + margin-top: 4px; +} + +.analysis-error { + padding: 10px; + border: 1px solid #fecaca; + border-radius: 8px; + background: #fef2f2; + color: #991b1b; + overflow-wrap: anywhere; +} + +.analysis-progress { + position: relative; + height: 5px; + margin: 16px 0; + overflow: hidden; + border-radius: 999px; + background: #dbeafe; +} + +.analysis-progress::after { + position: absolute; + inset: 0; + width: 40%; + border-radius: inherit; + background: #2563eb; + content: ""; + animation: analysis-progress 1.2s ease-in-out infinite; +} + +@keyframes analysis-progress { + from { + transform: translateX(-110%); + } + + to { + transform: translateX(360%); + } +} + +@media (prefers-reduced-motion: reduce) { + .analysis-progress::after { + animation: none; + width: 100%; + } +} + +.analysis-result-group { + margin: 16px 0; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; +} +.analysis-result-group summary { + padding: 11px 12px; + color: #1e293b; + font-size: 13px; + font-weight: 750; + cursor: pointer; +} +.analysis-result-content { + padding: 0 12px 12px; + border-top: 1px solid #e2e8f0; +} +.analysis-filter-label { + display: block; + margin: 12px 0 6px; + color: #475569; + font-size: 12px; + font-weight: 700; +} +.analysis-filter-input { + width: 100%; + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 7px; + outline: none; +} +.analysis-filter-input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13); +} +.analysis-result-range { + margin: 12px 0 8px; + color: #64748b; + font-size: 12px; +} +.terminal-state-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); + gap: 6px; +} +.terminal-state-list button { + min-width: 0; + padding: 7px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; +} +.analysis-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 12px; +} +.analysis-pagination span { + color: #64748b; + font-size: 11px; + white-space: nowrap; +} +.analysis-pagination button { + padding: 6px 8px; + font-size: 11px; +} + +.cyclic-component-list { + display: grid; + gap: 7px; +} +.cyclic-component-list button { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + width: 100%; + text-align: left; +} +.cyclic-component-list button.active { + border-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; +} +.cyclic-component-list button span { + font-size: 12px; + font-weight: 750; +} +.cyclic-component-list button small { + color: #64748b; + font-size: 10px; + font-weight: 600; + text-align: right; +} +.selected-component-details { + display: grid; + gap: 10px; + margin-top: 12px; + padding: 11px; + border: 1px solid #bfdbfe; + border-radius: 8px; + background: #eff6ff; +} +.selected-component-details > div { + display: grid; + gap: 3px; +} +.selected-component-details strong { + color: #1e3a8a; + font-size: 12px; +} +.selected-component-details span { + color: #475569; + font-size: 11px; + line-height: 1.4; +} +.selected-component-details button { + justify-self: start; + padding: 6px 8px; + font-size: 11px; +} + + +.path-search-form { display: grid; gap: 8px; } +.path-search-form > label, .path-search-number-row label { color: #475569; font-size: 12px; font-weight: 700; } +.path-search-form input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 7px; outline: none; } +.path-search-form input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13); } +.path-search-number-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 4px; } +.path-search-number-row > div { display: grid; gap: 6px; } +.path-search-status { margin-top: 16px; color: #475569; } +.path-search-result-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 18px 0 10px; } +.path-search-result-heading strong { color: #1e293b; font-size: 13px; } +.path-search-result-heading span { color: #64748b; font-size: 10px; text-align: right; } +.computed-path-list { display: grid; gap: 7px; } +.computed-path-card { overflow: hidden; border: 1px solid #cbd5e1; border-radius: 7px; background: #ffffff; } +.computed-path-card.active { border-color: #2563eb; background: #eff6ff; } +.computed-path-show { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-radius: 0; text-align: left; } +.computed-path-card.active .computed-path-show { background: #eff6ff; color: #1d4ed8; } +.computed-path-show span { font-size: 12px; font-weight: 750; } +.computed-path-show small { color: #64748b; font-size: 10px; font-weight: 600; } +.computed-path-details { border-top: 1px solid #e2e8f0; } +.computed-path-details summary { padding: 8px 10px; color: #475569; font-size: 11px; font-weight: 700; cursor: pointer; } +.computed-path-details ol { display: grid; gap: 7px; margin: 0; padding: 8px 10px 10px 32px; background: #f8fafc; } +.computed-path-details li { padding-left: 3px; color: #334155; font-size: 11px; } +.computed-path-details li strong, .computed-path-details li span, .computed-path-details li code { display: block; overflow-wrap: anywhere; } +.computed-path-details li span { margin-top: 2px; color: #64748b; } +.computed-path-details li code { margin-top: 2px; color: #475569; font-size: 10px; } +.computed-path-details p { margin: 0; padding: 9px 10px; background: #f8fafc; color: #64748b; font-size: 11px; } +.path-search-warning { margin: 12px 0 0; padding: 9px 10px; border: 1px solid #fde68a; border-radius: 7px; background: #fffbeb; color: #92400e; font-size: 11px; line-height: 1.45; } + +.path-search-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.path-search-heading h2 { + margin: 4px 0 0; +} + +.path-search-heading .clear-button { + flex: 0 0 auto; + max-width: 145px; + line-height: 1.25; +} + +.computed-path-details li.active { + margin-left: -4px; + padding: 6px 7px; + border-left: 3px solid #2563eb; + border-radius: 4px; + background: #dbeafe; +} + +.computed-step-transition, +.computed-step-edge-id, +.computed-step-route button { + min-width: 0; + padding: 0; + border: 0; + border-radius: 2px; + background: transparent; + text-align: left; +} + +.computed-step-transition { + display: block; + width: 100%; + color: #1e293b; + font-size: 11px; + font-weight: 750; + overflow-wrap: anywhere; +} + +.computed-step-route { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-top: 3px; + color: #64748b; +} + +.computed-step-route button { + color: #2563eb; + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + font-weight: 700; +} + +.computed-step-edge-id { + display: block; + width: 100%; + margin-top: 3px; + color: #475569; + font-family: Consolas, "Courier New", monospace; + font-size: 10px; + overflow-wrap: anywhere; +} + +.computed-step-transition:hover, +.computed-step-edge-id:hover, +.computed-step-route button:hover { + color: #1d4ed8; + text-decoration: underline; +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a2db340..5f9423f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,10 @@ import { parseGraphJsonText, serializeGraphJson, } from "./graph/graphJson"; +import { useGraphAnalysis } from "./graph/useGraphAnalysis"; +import { usePathSearch } from "./graph/usePathSearch"; +import type { BoundedPath } from "./graph/pathSearch"; +import type { StronglyConnectedComponent } from "./graph/graphAnalysis"; interface GraphNode { id: string; @@ -54,6 +58,7 @@ interface GraphData { } type OverviewLayout = "hierarchical" | "grid"; +type SidePanelMode = "inspector" | "analysis" | "paths"; interface InspectorInfo { type: "node" | "edge"; @@ -61,6 +66,21 @@ interface InspectorInfo { subtitle?: string; data: JsonValue; } +interface GraphViewSnapshot { + nodeIds: string[]; + edgeIds: string[]; + positions: Record; + zoom: number; + pan: { x: number; y: number }; + showingAll: boolean; + selectedStateId: string | null; + searchText: string; + hopCount: number; + overviewLayout: OverviewLayout; +} + +const TERMINAL_PAGE_SIZE = 100; +const SCC_PAGE_SIZE = 100; function App() { const graphContainer = useRef(null); @@ -72,6 +92,7 @@ function App() { const selectedPathRef = useRef(null); const showPathContextRef = useRef<(path: SelectedPath) => void>(() => {}); const addEdgeToPathRef = useRef<(edgeId: string) => void>(() => {}); + const graphViewBeforeComputedPathRef = useRef(null); const [status, setStatus] = useState("Select an LTS graph file to begin"); const [fileName, setFileName] = useState(""); @@ -87,6 +108,28 @@ function App() { const [pinnedInspector, setPinnedInspector] = useState(null); const [pathMode, setPathMode] = useState("idle"); const [selectedPath, setSelectedPath] = useState(null); + const [sidePanelMode, setSidePanelMode] = + useState("inspector"); + const graphAnalysis = useGraphAnalysis(); + const pathSearch = usePathSearch(); + const [pathSearchSource, setPathSearchSource] = useState(""); + const [pathSearchTarget, setPathSearchTarget] = useState(""); + const [requestedPathCount, setRequestedPathCount] = useState(5); + const [maximumVisitsPerState, setMaximumVisitsPerState] = useState(1); + const [shownSearchPathIndex, setShownSearchPathIndex] = useState(null); + const [computedPathViewActive, setComputedPathViewActive] = useState(false); + const [focusedComputedStepKey, setFocusedComputedStepKey] = useState(null); + const [terminalStatesExpanded, setTerminalStatesExpanded] = + useState(false); + const [terminalStateFilter, setTerminalStateFilter] = useState(""); + const [terminalStatePage, setTerminalStatePage] = useState(0); + const [cyclicComponentsExpanded, setCyclicComponentsExpanded] = + useState(false); + const [minimumCyclicComponentSize, setMinimumCyclicComponentSize] = + useState(1); + const [cyclicComponentPage, setCyclicComponentPage] = useState(0); + const [selectedCyclicComponentId, setSelectedCyclicComponentId] = + useState(null); function updatePathMode(mode: PathSelectionMode) { pathModeRef.current = mode; @@ -137,6 +180,26 @@ function App() { const visibleNodes = graph.nodes.filter((node) => visibleNodeIds.has(node.id) ); + const parallelEdgeGroups = new Map(); + + for (const edge of graph.edges) { + const key = `${edge.source}\u0000${edge.target}`; + const group = parallelEdgeGroups.get(key) ?? []; + group.push(edge); + parallelEdgeGroups.set(key, group); + } + + function getControlPointDistance(edge: GraphEdge): number { + const key = `${edge.source}\u0000${edge.target}`; + const group = parallelEdgeGroups.get(key) ?? [edge]; + + if (group.length <= 1 || edge.source === edge.target) { + return 0; + } + + const position = group.findIndex((candidate) => candidate.id === edge.id); + return (position - (group.length - 1) / 2) * 56; + } return [ ...visibleNodes.map((node) => ({ @@ -162,6 +225,7 @@ function App() { outputs_raw: edge.outputs_raw, lineColor: edge.color === "darkorange" ? "#f59e0b" : "#64748b", + controlPointDistance: getControlPointDistance(edge), }, })), ]; @@ -337,11 +401,17 @@ function App() { return; } + setSelectedCyclicComponentId(null); const requestedStateId = selectedStateId ?? searchText.trim(); const startStateId = graph.nodes.some((node) => node.id === requestedStateId) ? requestedStateId : null; + if (selectedCyclicComponentId !== null && startStateId) { + setSelectedCyclicComponentId(null); + showNeighborhood(startStateId, hopCount); + } + cyRef.current?.elements().removeClass( "path-node path-edge path-start path-end path-next-edge path-next-node path-dimmed" ); @@ -671,6 +741,151 @@ function App() { } } + function runGraphAnalysis() { + const graph = graphRef.current; + + if (!graph) { + setStatus("Open a graph before running analysis"); + return; + } + + graphAnalysis.run({ + nodeIds: graph.nodes.map((node) => node.id), + edges: graph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })), + }); + } + + function runPathSearch(event?: FormEvent) { + event?.preventDefault(); + const graph = graphRef.current; + const sourceNodeId = pathSearchSource.trim(); + const targetNodeId = pathSearchTarget.trim(); + if (!graph) return; + if (!graph.nodes.some((node) => node.id === sourceNodeId)) { + setStatus(`Source state ${sourceNodeId || "(empty)"} was not found`); + return; + } + if (!graph.nodes.some((node) => node.id === targetNodeId)) { + setStatus(`Target state ${targetNodeId || "(empty)"} was not found`); + return; + } + setShownSearchPathIndex(null); + pathSearch.run({ + nodeIds: graph.nodes.map((node) => node.id), + edges: graph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })), + sourceNodeId, + targetNodeId, + requestedPathCount, + maximumVisitsPerState, + constraints: {}, + }); + setStatus(`Searching for up to ${requestedPathCount} paths from ${sourceNodeId} to ${targetNodeId}`); + } + + function captureGraphViewBeforeComputedPath() { + const cy = cyRef.current; + if (!cy || graphViewBeforeComputedPathRef.current) return; + + const positions: Record = {}; + cy.nodes().forEach((node) => { + positions[node.id()] = { ...node.position() }; + }); + + graphViewBeforeComputedPathRef.current = { + nodeIds: cy.nodes().map((node) => node.id()), + edgeIds: cy.edges().map((edge) => edge.id()), + positions, + zoom: cy.zoom(), + pan: { ...cy.pan() }, + showingAll, + selectedStateId, + searchText, + hopCount, + overviewLayout, + }; + } + + function returnToGraphView() { + const graph = graphRef.current; + const cy = cyRef.current; + const snapshot = graphViewBeforeComputedPathRef.current; + if (!graph || !cy || !snapshot) return; + + const nodeIds = new Set(snapshot.nodeIds); + const edgeIds = new Set(snapshot.edgeIds); + const edges = graph.edges.filter((edge) => edgeIds.has(edge.id)); + + cy.startBatch(); + cy.elements().remove(); + cy.add(buildElements(graph, nodeIds, edges, showTransitionLabels)); + cy.nodes().forEach((node) => { + const position = snapshot.positions[node.id()]; + if (position) node.position(position); + }); + cy.nodes().unlock(); + cy.nodes().grabify(); + cy.elements().removeClass( + "path-node path-edge path-start path-end path-next-edge path-next-node path-dimmed" + ); + cy.endBatch(); + cy.resize(); + cy.zoom(snapshot.zoom); + cy.pan(snapshot.pan); + + updateSelectedPath(null); + updatePathMode("idle"); + setShowingAll(snapshot.showingAll); + setSelectedStateId(snapshot.selectedStateId); + setSearchText(snapshot.searchText); + setHopCount(snapshot.hopCount); + setOverviewLayout(snapshot.overviewLayout); + setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); + setComputedPathViewActive(false); + graphViewBeforeComputedPathRef.current = null; + setStatus("Returned to the previous graph view"); + } + + function showComputedPath(path: BoundedPath, index: number) { + const graph = graphRef.current; + if (!graph) return; + const selected: SelectedPath = { + startNodeId: path.startNodeId, + edgeIds: path.edgeIds, + }; + try { + resolvePath(graph, selected); + captureGraphViewBeforeComputedPath(); + setSelectedCyclicComponentId(null); + updateSelectedPath(selected); + updatePathMode("idle"); + showPathContext(selected); + setShownSearchPathIndex(index); + setFocusedComputedStepKey(null); + setComputedPathViewActive(true); + window.setTimeout(() => { + const cy = cyRef.current; + if (cy && cy.elements().nonempty()) { + cy.animate({ + fit: { eles: cy.elements(), padding: 60 }, + duration: 250, + }); + } + }, 0); + setStatus(`Showing computed path ${index + 1} with ${path.edgeIds.length} transitions`); + } catch (error) { + setStatus(error instanceof Error ? error.message : "Could not display the computed path"); + } + } + async function handleFileSelected(event: ChangeEvent) { const file = event.target.files?.[0]; @@ -678,6 +893,19 @@ function App() { return; } + graphAnalysis.reset(); + pathSearch.reset(); + setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); + setComputedPathViewActive(false); + graphViewBeforeComputedPathRef.current = null; + setTerminalStatesExpanded(false); + setTerminalStateFilter(""); + setTerminalStatePage(0); + setCyclicComponentsExpanded(false); + setMinimumCyclicComponentSize(1); + setCyclicComponentPage(0); + setSelectedCyclicComponentId(null); setStatus(`Loading ${file.name}...`); setFileName(file.name); setInspectorInfo(null); @@ -701,6 +929,12 @@ function App() { return; } + const defaultPathState = graph.nodes.some((node) => node.id === "0") + ? "0" + : graph.nodes[0].id; + setPathSearchSource(defaultPathState); + setPathSearchTarget(defaultPathState); + if (importedPath) { const resolved = resolvePath(graph, importedPath); const allNodeIds = new Set(graph.nodes.map((node) => node.id)); @@ -786,7 +1020,9 @@ function App() { "target-arrow-color": "data(lineColor)", "target-arrow-shape": "triangle", "arrow-scale": 0.8, - "curve-style": "bezier", + "curve-style": "unbundled-bezier", + "control-point-distances": "data(controlPointDistance)", + "control-point-weights": 0.5, label: "data(label)", "font-size": "9px", color: "#334155", @@ -1039,6 +1275,204 @@ function App() { target: edge.target, })); })(); + + const filteredTerminalNodeIds = (() => { + const terminalNodeIds = graphAnalysis.result?.terminalNodeIds ?? []; + const filter = terminalStateFilter.trim().toLocaleLowerCase(); + + if (!filter) return terminalNodeIds; + + return terminalNodeIds.filter((nodeId) => + nodeId.toLocaleLowerCase().includes(filter) + ); + })(); + const terminalPageCount = Math.max( + 1, + Math.ceil(filteredTerminalNodeIds.length / TERMINAL_PAGE_SIZE) + ); + const safeTerminalStatePage = Math.min( + terminalStatePage, + terminalPageCount - 1 + ); + const visibleTerminalNodeIds = filteredTerminalNodeIds.slice( + safeTerminalStatePage * TERMINAL_PAGE_SIZE, + (safeTerminalStatePage + 1) * TERMINAL_PAGE_SIZE + ); + const terminalResultStart = + filteredTerminalNodeIds.length === 0 + ? 0 + : safeTerminalStatePage * TERMINAL_PAGE_SIZE + 1; + const terminalResultEnd = Math.min( + (safeTerminalStatePage + 1) * TERMINAL_PAGE_SIZE, + filteredTerminalNodeIds.length + ); + + function focusTerminalState(stateId: string) { + setSelectedCyclicComponentId(null); + showNeighborhood(stateId, hopCount); + setStatus(`Focused terminal state ${stateId}`); + } + + const filteredCyclicComponents = + graphAnalysis.result?.cyclicComponents.filter( + (component) => component.nodeIds.length >= minimumCyclicComponentSize + ) ?? []; + const cyclicComponentPageCount = Math.max( + 1, + Math.ceil(filteredCyclicComponents.length / SCC_PAGE_SIZE) + ); + const safeCyclicComponentPage = Math.min( + cyclicComponentPage, + cyclicComponentPageCount - 1 + ); + const visibleCyclicComponents = filteredCyclicComponents.slice( + safeCyclicComponentPage * SCC_PAGE_SIZE, + (safeCyclicComponentPage + 1) * SCC_PAGE_SIZE + ); + const cyclicComponentResultStart = + filteredCyclicComponents.length === 0 + ? 0 + : safeCyclicComponentPage * SCC_PAGE_SIZE + 1; + const cyclicComponentResultEnd = Math.min( + (safeCyclicComponentPage + 1) * SCC_PAGE_SIZE, + filteredCyclicComponents.length + ); + const selectedCyclicComponent = + graphAnalysis.result?.cyclicComponents.find( + (component) => component.id === selectedCyclicComponentId + ) ?? null; + + function getCyclicComponentNumber(componentId: number): number { + const index = graphAnalysis.result?.cyclicComponents.findIndex( + (component) => component.id === componentId + ) ?? -1; + + return index >= 0 ? index + 1 : 0; + } + + function showCyclicComponent(component: StronglyConnectedComponent) { + const graph = graphRef.current; + const cy = cyRef.current; + if (!graph || !cy) return; + + const componentNodeIds = new Set(component.nodeIds); + const componentEdgeIds = new Set(component.internalEdgeIds); + const componentEdges = graph.edges.filter( + (edge) => + componentEdgeIds.has(edge.id) && + componentNodeIds.has(edge.source) && + componentNodeIds.has(edge.target) + ); + const componentNumber = getCyclicComponentNumber(component.id); + + updateSelectedPath(null); + updatePathMode("idle"); + + // Replace the canvas contents and then defensively remove anything that + // does not belong to the selected SCC. This guarantees an exact component + // view even if Cytoscape retained elements from a previous view. + replaceVisibleGraph( + buildElements(graph, componentNodeIds, componentEdges, showTransitionLabels), + overviewLayout === "hierarchical" ? "breadthfirst" : "grid" + ); + + cy.startBatch(); + cy.nodes() + .filter((node) => !componentNodeIds.has(node.id())) + .remove(); + cy.edges() + .filter((edge) => !componentEdgeIds.has(edge.id())) + .remove(); + cy.elements().removeClass( + "path-node path-edge path-start path-end path-next-edge path-next-node path-dimmed" + ); + cy.endBatch(); + cy.resize(); + + setSelectedCyclicComponentId(component.id); + setShowingAll(false); + setSelectedStateId(component.nodeIds[0] ?? null); + if (component.nodeIds[0]) setSearchText(component.nodeIds[0]); + setStatus( + `Showing cyclic component ${componentNumber}: ${component.nodeIds.length} states and ${component.internalEdgeIds.length} internal transitions.` + ); + } + + function clearCyclicComponentView() { + setSelectedCyclicComponentId(null); + const graph = graphRef.current; + if (!graph) return; + + const stateId = selectedStateId ?? graph.nodes[0]?.id; + if (stateId) showNeighborhood(stateId, hopCount); + } + + function focusComputedPathEdge(edgeId: string, stepKey: string) { + const cy = cyRef.current; + const edge = cy?.getElementById(edgeId); + if (!cy || !edge || edge.empty()) { + setStatus(`Transition ${edgeId} is not visible. Show the path first.`); + return; + } + + cy.elements().unselect(); + edge.select(); + const info = makeEdgeInspector(edge); + pinnedInspectorRef.current = info; + setPinnedInspector(info); + setInspectorInfo(info); + setFocusedComputedStepKey(stepKey); + cy.animate({ + center: { eles: edge }, + duration: 250, + }); + setStatus(`Focused transition ${edge.data("transition") ?? edgeId}`); + } + + function focusComputedPathNode(nodeId: string, stepKey: string) { + const cy = cyRef.current; + const node = cy?.getElementById(nodeId); + if (!cy || !node || node.empty()) { + setStatus(`State ${nodeId} is not visible. Show the path first.`); + return; + } + + cy.elements().unselect(); + node.select(); + const info = makeNodeInspector(node); + pinnedInspectorRef.current = info; + setPinnedInspector(info); + setInspectorInfo(info); + setFocusedComputedStepKey(stepKey); + setSelectedStateId(nodeId); + setSearchText(nodeId); + cy.animate({ + center: { eles: node }, + duration: 250, + }); + setStatus(`Focused state ${nodeId}`); + } + + function getComputedPathSteps(path: BoundedPath) { + const graph = graphRef.current; + if (!graph) return []; + + try { + return resolvePath(graph, { + startNodeId: path.startNodeId, + edgeIds: path.edgeIds, + }).edges.map((edge, index) => ({ + index, + id: edge.id, + source: edge.source, + target: edge.target, + transition: edge.transition, + })); + } catch { + return []; + } + } + return (
@@ -1171,54 +1605,553 @@ function App() { diff --git a/frontend/src/graph/graphAnalysis.test.ts b/frontend/src/graph/graphAnalysis.test.ts new file mode 100644 index 0000000..6937004 --- /dev/null +++ b/frontend/src/graph/graphAnalysis.test.ts @@ -0,0 +1,904 @@ +import { describe, expect, it } from "vitest"; + +import { + analyzeGraph, + findStronglyConnectedComponents, + findTerminalNodeIds, + type GraphAnalysisInput, +} from "./graphAnalysis"; + +function graph( + nodeIds: string[], + edges: GraphAnalysisInput["edges"], +): GraphAnalysisInput { + return { + nodeIds, + edges, + }; +} + +describe("findTerminalNodeIds", () => { + it("returns no terminal states for an empty graph", () => { + expect( + findTerminalNodeIds(graph([], [])), + ).toEqual([]); + }); + + it("returns an isolated state as terminal", () => { + expect( + findTerminalNodeIds(graph(["0"], [])), + ).toEqual(["0"]); + }); + + it("returns the final state of a linear graph", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1", "2"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + { + id: "edge-1", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual(["2"]); + }); + + it("returns multiple terminal states in node order", () => { + expect( + findTerminalNodeIds( + graph( + ["start", "left", "right"], + [ + { + id: "edge-left", + source: "start", + target: "left", + }, + { + id: "edge-right", + source: "start", + target: "right", + }, + ], + ), + ), + ).toEqual(["left", "right"]); + }); + + it("does not classify a state with a self-loop as terminal", () => { + expect( + findTerminalNodeIds( + graph( + ["loop"], + [ + { + id: "self-loop", + source: "loop", + target: "loop", + }, + ], + ), + ), + ).toEqual([]); + }); + + it("handles parallel outgoing edges", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1"], + [ + { + id: "edge-a", + source: "0", + target: "1", + }, + { + id: "edge-b", + source: "0", + target: "1", + }, + ], + ), + ), + ).toEqual(["1"]); + }); + + it("handles disconnected graph regions", () => { + expect( + findTerminalNodeIds( + graph( + ["a", "b", "c", "d", "isolated"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + ], + ), + ), + ).toEqual(["b", "d", "isolated"]); + }); + + it("supports non-numeric node IDs", () => { + expect( + findTerminalNodeIds( + graph( + ["ready", "processing", "completed"], + [ + { + id: "start-processing", + source: "ready", + target: "processing", + }, + { + id: "finish-processing", + source: "processing", + target: "completed", + }, + ], + ), + ), + ).toEqual(["completed"]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + ], + ); + + const originalInput = structuredClone(input); + + findTerminalNodeIds(input); + + expect(input).toEqual(originalInput); + }); + + it("returns duplicate node IDs only once", () => { + expect( + findTerminalNodeIds( + graph( + ["0", "1", "1"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + ], + ), + ), + ).toEqual(["1"]); + }); + + it("ignores outgoing edges from IDs absent from nodeIds", () => { + expect( + findTerminalNodeIds( + graph( + ["known"], + [ + { + id: "external-edge", + source: "external", + target: "known", + }, + ], + ), + ), + ).toEqual(["known"]); + }); +}); + +describe("findStronglyConnectedComponents", () => { + it("returns no components for an empty graph", () => { + expect( + findStronglyConnectedComponents(graph([], [])), + ).toEqual([]); + }); + + it("returns an isolated state as a non-cyclic component", () => { + expect( + findStronglyConnectedComponents( + graph(["0"], []), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("classifies a self-loop component as cyclic", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0"], + [ + { + id: "self-loop", + source: "0", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: ["self-loop"], + isCyclic: true, + }, + ]); + }); + + it("returns one component per state in a linear graph", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-0", + source: "0", + target: "1", + }, + { + id: "edge-1", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["1"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 2, + nodeIds: ["2"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("finds a simple directed cycle", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + { + id: "edge-20", + source: "2", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1", "2"], + internalEdgeIds: [ + "edge-01", + "edge-12", + "edge-20", + ], + isCyclic: true, + }, + ]); + }); + + it("finds two disconnected cycles", () => { + expect( + findStronglyConnectedComponents( + graph( + ["a", "b", "c", "d"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + { + id: "edge-dc", + source: "d", + target: "c", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 1, + nodeIds: ["c", "d"], + internalEdgeIds: ["edge-cd", "edge-dc"], + isCyclic: true, + }, + ]); + }); + + it("separates a cycle from its outgoing tail", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-10", + source: "1", + target: "0", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1"], + internalEdgeIds: ["edge-01", "edge-10"], + isCyclic: true, + }, + { + id: 1, + nodeIds: ["2"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("separates an incoming state from a cycle", () => { + expect( + findStronglyConnectedComponents( + graph( + ["entry", "a", "b"], + [ + { + id: "edge-entry-a", + source: "entry", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["entry"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + ]); + }); + + it("preserves parallel internal edge IDs", () => { + expect( + findStronglyConnectedComponents( + graph( + ["0", "1"], + [ + { + id: "edge-forward-a", + source: "0", + target: "1", + }, + { + id: "edge-forward-b", + source: "0", + target: "1", + }, + { + id: "edge-back", + source: "1", + target: "0", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["0", "1"], + internalEdgeIds: [ + "edge-forward-a", + "edge-forward-b", + "edge-back", + ], + isCyclic: true, + }, + ]); + }); + + it("handles mixed cyclic and acyclic regions", () => { + const components = findStronglyConnectedComponents( + graph( + ["start", "a", "b", "end", "isolated"], + [ + { + id: "edge-start-a", + source: "start", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-b-end", + source: "b", + target: "end", + }, + ], + ), + ); + + expect(components).toEqual([ + { + id: 0, + nodeIds: ["start"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 2, + nodeIds: ["end"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 3, + nodeIds: ["isolated"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("ignores edges that reference unknown nodes", () => { + expect( + findStronglyConnectedComponents( + graph( + ["known"], + [ + { + id: "unknown-source", + source: "external", + target: "known", + }, + { + id: "unknown-target", + source: "known", + target: "external", + }, + ], + ), + ), + ).toEqual([ + { + id: 0, + nodeIds: ["known"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-10", + source: "1", + target: "0", + }, + ], + ); + + const originalInput = structuredClone(input); + + findStronglyConnectedComponents(input); + + expect(input).toEqual(originalInput); + }); + + it("handles a long graph without recursive calls", () => { + const nodeCount = 20_000; + const nodeIds = Array.from( + { length: nodeCount }, + (_, index) => String(index), + ); + + const edges = Array.from( + { length: nodeCount - 1 }, + (_, index) => ({ + id: `edge-${index}`, + source: String(index), + target: String(index + 1), + }), + ); + + const components = findStronglyConnectedComponents( + graph(nodeIds, edges), + ); + + expect(components).toHaveLength(nodeCount); + expect(components[0].nodeIds).toEqual(["0"]); + expect(components[nodeCount - 1].nodeIds).toEqual([ + String(nodeCount - 1), + ]); + expect( + components.every((component) => !component.isCyclic), + ).toBe(true); + }); + + it("handles one large strongly connected component", () => { + const nodeCount = 10_000; + const nodeIds = Array.from( + { length: nodeCount }, + (_, index) => String(index), + ); + + const edges = Array.from( + { length: nodeCount }, + (_, index) => ({ + id: `edge-${index}`, + source: String(index), + target: String((index + 1) % nodeCount), + }), + ); + + const components = findStronglyConnectedComponents( + graph(nodeIds, edges), + ); + + expect(components).toHaveLength(1); + expect(components[0].nodeIds).toHaveLength(nodeCount); + expect(components[0].internalEdgeIds).toHaveLength( + nodeCount, + ); + expect(components[0].isCyclic).toBe(true); + }); +}); + +describe("analyzeGraph", () => { + it("returns an empty result for an empty graph", () => { + expect(analyzeGraph(graph([], []))).toEqual({ + terminalNodeIds: [], + components: [], + cyclicComponents: [], + statesInCyclicComponents: 0, + largestCyclicComponentSize: 0, + }); + }); + + it("combines terminal and component analysis", () => { + const result = analyzeGraph( + graph( + ["start", "a", "b", "end", "isolated"], + [ + { + id: "edge-start-a", + source: "start", + target: "a", + }, + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-b-end", + source: "b", + target: "end", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual([ + "end", + "isolated", + ]); + expect(result.components).toEqual([ + { + id: 0, + nodeIds: ["start"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + { + id: 2, + nodeIds: ["end"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 3, + nodeIds: ["isolated"], + internalEdgeIds: [], + isCyclic: false, + }, + ]); + expect(result.cyclicComponents).toEqual([ + { + id: 1, + nodeIds: ["a", "b"], + internalEdgeIds: ["edge-ab", "edge-ba"], + isCyclic: true, + }, + ]); + expect(result.statesInCyclicComponents).toBe(2); + expect(result.largestCyclicComponentSize).toBe(2); + }); + + it("counts a self-loop as a cyclic component", () => { + const result = analyzeGraph( + graph( + ["loop", "terminal"], + [ + { + id: "self-loop", + source: "loop", + target: "loop", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual(["terminal"]); + expect(result.cyclicComponents).toHaveLength(1); + expect(result.cyclicComponents[0].nodeIds).toEqual([ + "loop", + ]); + expect(result.statesInCyclicComponents).toBe(1); + expect(result.largestCyclicComponentSize).toBe(1); + }); + + it("orders cyclic components by descending size", () => { + const result = analyzeGraph( + graph( + ["a", "b", "x", "y", "z"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-xy", + source: "x", + target: "y", + }, + { + id: "edge-yz", + source: "y", + target: "z", + }, + { + id: "edge-zx", + source: "z", + target: "x", + }, + ], + ), + ); + + expect( + result.cyclicComponents.map( + (component) => component.nodeIds, + ), + ).toEqual([ + ["x", "y", "z"], + ["a", "b"], + ]); + expect(result.statesInCyclicComponents).toBe(5); + expect(result.largestCyclicComponentSize).toBe(3); + }); + + it("preserves component order when cyclic sizes are equal", () => { + const result = analyzeGraph( + graph( + ["a", "b", "c", "d"], + [ + { + id: "edge-ab", + source: "a", + target: "b", + }, + { + id: "edge-ba", + source: "b", + target: "a", + }, + { + id: "edge-cd", + source: "c", + target: "d", + }, + { + id: "edge-dc", + source: "d", + target: "c", + }, + ], + ), + ); + + expect( + result.cyclicComponents.map( + (component) => component.nodeIds, + ), + ).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + + it("does not modify the input", () => { + const input = graph( + ["0", "1"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + ], + ); + const originalInput = structuredClone(input); + + analyzeGraph(input); + + expect(input).toEqual(originalInput); + }); + + it("returns zero cyclic statistics for an acyclic graph", () => { + const result = analyzeGraph( + graph( + ["0", "1", "2"], + [ + { + id: "edge-01", + source: "0", + target: "1", + }, + { + id: "edge-12", + source: "1", + target: "2", + }, + ], + ), + ); + + expect(result.terminalNodeIds).toEqual(["2"]); + expect(result.components).toHaveLength(3); + expect(result.cyclicComponents).toEqual([]); + expect(result.statesInCyclicComponents).toBe(0); + expect(result.largestCyclicComponentSize).toBe(0); + }); +}); diff --git a/frontend/src/graph/graphAnalysis.ts b/frontend/src/graph/graphAnalysis.ts new file mode 100644 index 0000000..8a5ddf9 --- /dev/null +++ b/frontend/src/graph/graphAnalysis.ts @@ -0,0 +1,365 @@ +export type GraphAnalysisEdge = { + id: string; + source: string; + target: string; +}; + +export type GraphAnalysisInput = { + nodeIds: string[]; + edges: GraphAnalysisEdge[]; +}; + +export type StronglyConnectedComponent = { + id: number; + nodeIds: string[]; + internalEdgeIds: string[]; + isCyclic: boolean; +}; + +export type GraphAnalysisResult = { + terminalNodeIds: string[]; + components: StronglyConnectedComponent[]; + cyclicComponents: StronglyConnectedComponent[]; + statesInCyclicComponents: number; + largestCyclicComponentSize: number; +}; + +type GraphTopology = { + nodeIds: string[]; + nodeOrder: Map; + outgoing: Map; + incoming: Map; + validEdges: GraphAnalysisEdge[]; + selfLoopNodeIds: Set; +}; + +/** + * Builds a normalized topology containing only unique nodes listed in + * input.nodeIds and edges whose source and target are both known nodes. + * + * Normal graph validation should reject unknown references before analysis. + * Ignoring unknown references here keeps the pure analysis functions robust + * when used independently. + */ +function buildTopology(input: GraphAnalysisInput): GraphTopology { + const nodeIds: string[] = []; + const nodeOrder = new Map(); + + for (const nodeId of input.nodeIds) { + if (nodeOrder.has(nodeId)) { + continue; + } + + nodeOrder.set(nodeId, nodeIds.length); + nodeIds.push(nodeId); + } + + const outgoing = new Map(); + const incoming = new Map(); + + for (const nodeId of nodeIds) { + outgoing.set(nodeId, []); + incoming.set(nodeId, []); + } + + const validEdges: GraphAnalysisEdge[] = []; + const selfLoopNodeIds = new Set(); + + for (const edge of input.edges) { + if ( + !nodeOrder.has(edge.source) || + !nodeOrder.has(edge.target) + ) { + continue; + } + + outgoing.get(edge.source)?.push(edge.target); + incoming.get(edge.target)?.push(edge.source); + validEdges.push(edge); + + if (edge.source === edge.target) { + selfLoopNodeIds.add(edge.source); + } + } + + return { + nodeIds, + nodeOrder, + outgoing, + incoming, + validEdges, + selfLoopNodeIds, + }; +} + +function findTerminalNodeIdsFromTopology( + topology: GraphTopology, +): string[] { + return topology.nodeIds.filter( + (nodeId) => topology.outgoing.get(nodeId)?.length === 0, + ); +} + +/** + * Returns states with no outgoing transitions. + * + * The result follows the order of input.nodeIds. Duplicate node IDs in the + * input are returned only once. A state with a self-loop is not terminal. + * + * This function intentionally performs only topology analysis. It does not + * decide whether a terminal state represents successful completion or a + * deadlock. + */ +export function findTerminalNodeIds( + input: GraphAnalysisInput, +): string[] { + return findTerminalNodeIdsFromTopology(buildTopology(input)); +} + +/** + * Computes the finishing order of an iterative depth-first traversal. + * + * An explicit stack is used instead of recursive calls so long paths do not + * risk exceeding the JavaScript call-stack limit. + */ +function computeFinishingOrder( + nodeIds: string[], + adjacency: Map, +): string[] { + const visited = new Set(); + const finishingOrder: string[] = []; + + for (const startNodeId of nodeIds) { + if (visited.has(startNodeId)) { + continue; + } + + visited.add(startNodeId); + + const stack: Array<{ + nodeId: string; + nextNeighborIndex: number; + }> = [ + { + nodeId: startNodeId, + nextNeighborIndex: 0, + }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const neighbors = adjacency.get(frame.nodeId) ?? []; + + if (frame.nextNeighborIndex < neighbors.length) { + const neighborId = neighbors[frame.nextNeighborIndex]; + frame.nextNeighborIndex += 1; + + if (!visited.has(neighborId)) { + visited.add(neighborId); + stack.push({ + nodeId: neighborId, + nextNeighborIndex: 0, + }); + } + + continue; + } + + finishingOrder.push(frame.nodeId); + stack.pop(); + } + } + + return finishingOrder; +} + +/** Collects one component using an iterative traversal. */ +function collectComponent( + startNodeId: string, + adjacency: Map, + assignedNodeIds: Set, +): string[] { + const componentNodeIds: string[] = []; + const stack = [startNodeId]; + + assignedNodeIds.add(startNodeId); + + while (stack.length > 0) { + const nodeId = stack.pop(); + + if (nodeId === undefined) { + continue; + } + + componentNodeIds.push(nodeId); + + const neighbors = adjacency.get(nodeId) ?? []; + + // Reverse iteration preserves adjacency order with a LIFO stack. + for ( + let neighborIndex = neighbors.length - 1; + neighborIndex >= 0; + neighborIndex -= 1 + ) { + const neighborId = neighbors[neighborIndex]; + + if (!assignedNodeIds.has(neighborId)) { + assignedNodeIds.add(neighborId); + stack.push(neighborId); + } + } + } + + return componentNodeIds; +} + +function findStronglyConnectedComponentsFromTopology( + topology: GraphTopology, +): StronglyConnectedComponent[] { + if (topology.nodeIds.length === 0) { + return []; + } + + const finishingOrder = computeFinishingOrder( + topology.nodeIds, + topology.outgoing, + ); + + const assignedNodeIds = new Set(); + const componentNodeIdGroups: string[][] = []; + + for ( + let orderIndex = finishingOrder.length - 1; + orderIndex >= 0; + orderIndex -= 1 + ) { + const startNodeId = finishingOrder[orderIndex]; + + if (assignedNodeIds.has(startNodeId)) { + continue; + } + + const componentNodeIds = collectComponent( + startNodeId, + topology.incoming, + assignedNodeIds, + ); + + componentNodeIds.sort( + (leftNodeId, rightNodeId) => + (topology.nodeOrder.get(leftNodeId) ?? 0) - + (topology.nodeOrder.get(rightNodeId) ?? 0), + ); + + componentNodeIdGroups.push(componentNodeIds); + } + + componentNodeIdGroups.sort((left, right) => { + const leftOrder = topology.nodeOrder.get(left[0]) ?? 0; + const rightOrder = topology.nodeOrder.get(right[0]) ?? 0; + + return leftOrder - rightOrder; + }); + + const componentIndexByNodeId = new Map(); + + componentNodeIdGroups.forEach( + (componentNodeIds, componentIndex) => { + for (const nodeId of componentNodeIds) { + componentIndexByNodeId.set(nodeId, componentIndex); + } + }, + ); + + const internalEdgeIdsByComponent = + componentNodeIdGroups.map(() => [] as string[]); + + for (const edge of topology.validEdges) { + const sourceComponentIndex = + componentIndexByNodeId.get(edge.source); + const targetComponentIndex = + componentIndexByNodeId.get(edge.target); + + if ( + sourceComponentIndex !== undefined && + sourceComponentIndex === targetComponentIndex + ) { + internalEdgeIdsByComponent[sourceComponentIndex].push( + edge.id, + ); + } + } + + return componentNodeIdGroups.map( + (componentNodeIds, componentIndex) => ({ + id: componentIndex, + nodeIds: componentNodeIds, + internalEdgeIds: + internalEdgeIdsByComponent[componentIndex], + isCyclic: + componentNodeIds.length > 1 || + topology.selfLoopNodeIds.has(componentNodeIds[0]), + }), + ); +} + +/** + * Finds all strongly connected components in a directed graph. + * + * The implementation uses an iterative two-pass depth-first traversal. + * Explicit stacks avoid recursion-depth failures on long graphs. + * Components are returned in deterministic graph-node order. + */ +export function findStronglyConnectedComponents( + input: GraphAnalysisInput, +): StronglyConnectedComponent[] { + return findStronglyConnectedComponentsFromTopology( + buildTopology(input), + ); +} + +/** + * Computes the complete topology analysis while constructing the normalized + * graph topology only once. + * + * Cyclic components are ordered by descending state count. Components with + * the same size retain their deterministic component order. + */ +export function analyzeGraph( + input: GraphAnalysisInput, +): GraphAnalysisResult { + const topology = buildTopology(input); + const terminalNodeIds = + findTerminalNodeIdsFromTopology(topology); + const components = + findStronglyConnectedComponentsFromTopology(topology); + + const cyclicComponents = components + .filter((component) => component.isCyclic) + .sort((left, right) => { + const sizeDifference = + right.nodeIds.length - left.nodeIds.length; + + if (sizeDifference !== 0) { + return sizeDifference; + } + + return left.id - right.id; + }); + + const statesInCyclicComponents = cyclicComponents.reduce( + (total, component) => total + component.nodeIds.length, + 0, + ); + + const largestCyclicComponentSize = + cyclicComponents[0]?.nodeIds.length ?? 0; + + return { + terminalNodeIds, + components, + cyclicComponents, + statesInCyclicComponents, + largestCyclicComponentSize, + }; +} diff --git a/frontend/src/graph/graphAnalysisController.test.ts b/frontend/src/graph/graphAnalysisController.test.ts new file mode 100644 index 0000000..86772e1 --- /dev/null +++ b/frontend/src/graph/graphAnalysisController.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; + +import type { GraphAnalysisResult } from "./graphAnalysis"; +import { + GraphAnalysisController, + type GraphAnalysisState, + type GraphAnalysisWorkerLike, +} from "./graphAnalysisController"; + +class FakeWorker implements GraphAnalysisWorkerLike { + public postedMessages: unknown[] = []; + public terminateCount = 0; + public onmessage: ((event: MessageEvent) => void) | null = null; + public onerror: ((event: ErrorEvent) => void) | null = null; + + public postMessage(message: unknown): void { + this.postedMessages.push(message); + } + + public terminate(): void { + this.terminateCount += 1; + } + + public respond(value: unknown): void { + this.onmessage?.({ data: value } as MessageEvent); + } + + public fail(message: string): void { + this.onerror?.({ message } as ErrorEvent); + } +} + +const INPUT = { + nodeIds: ["0", "1"], + edges: [ + { + id: "edge-01", + source: "0", + target: "1", + }, + ], +}; + +const RESULT: GraphAnalysisResult = { + terminalNodeIds: ["1"], + components: [ + { + id: 0, + nodeIds: ["0"], + internalEdgeIds: [], + isCyclic: false, + }, + { + id: 1, + nodeIds: ["1"], + internalEdgeIds: [], + isCyclic: false, + }, + ], + cyclicComponents: [], + statesInCyclicComponents: 0, + largestCyclicComponentSize: 0, +}; + +function setup() { + const workers: FakeWorker[] = []; + const states: GraphAnalysisState[] = []; + const controller = new GraphAnalysisController( + () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + (state) => states.push(state), + ); + + return { controller, workers, states }; +} + +function getRequestId(worker: FakeWorker): string { + const request = worker.postedMessages[0] as { + requestId: string; + }; + return request.requestId; +} + +describe("GraphAnalysisController", () => { + it("starts analysis and posts a typed request", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(states).toEqual([ + { + status: "running", + result: null, + error: null, + }, + ]); + expect(workers[0].postedMessages).toEqual([ + { + type: "analyze", + requestId: "graph-analysis-1", + input: INPUT, + }, + ]); + }); + + it("stores a successful result and terminates the worker", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const requestId = getRequestId(workers[0]); + workers[0].respond({ + type: "success", + requestId, + result: RESULT, + }); + + expect(controller.getState()).toEqual({ + status: "completed", + result: RESULT, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("stores a worker error response", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const requestId = getRequestId(workers[0]); + workers[0].respond({ + type: "error", + requestId, + error: "Analysis failed.", + }); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Analysis failed.", + }); + }); + + it("stores a worker runtime error", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].fail("Worker crashed."); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Worker crashed.", + }); + }); + + it("cancels a running analysis", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.cancel(); + + expect(controller.getState()).toEqual({ + status: "cancelled", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("ignores cancellation when analysis is not running", () => { + const { controller, workers, states } = setup(); + + controller.cancel(); + + expect(workers).toEqual([]); + expect(states).toEqual([]); + expect(controller.getState().status).toBe("not-run"); + }); + + it("terminates the previous worker when analysis is run again", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.run(INPUT); + + expect(workers).toHaveLength(2); + expect(workers[0].terminateCount).toBe(1); + expect(workers[1].postedMessages[0]).toMatchObject({ + requestId: "graph-analysis-2", + }); + }); + + it("ignores a stale response from an earlier request", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const firstWorker = workers[0]; + const firstRequestId = getRequestId(firstWorker); + + controller.run(INPUT); + const secondWorker = workers[1]; + const secondRequestId = getRequestId(secondWorker); + + firstWorker.respond({ + type: "success", + requestId: firstRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("running"); + + secondWorker.respond({ + type: "success", + requestId: secondRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("completed"); + }); + + it("ignores malformed worker responses", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].respond({ type: "success" }); + + expect(controller.getState().status).toBe("running"); + expect(workers[0].terminateCount).toBe(0); + }); + + it("resets state and terminates active work", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.reset(); + + expect(controller.getState()).toEqual({ + status: "not-run", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("disposes active work and ignores future runs", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + controller.dispose(); + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(workers[0].terminateCount).toBe(1); + expect(states).toHaveLength(1); + }); +}); diff --git a/frontend/src/graph/graphAnalysisController.ts b/frontend/src/graph/graphAnalysisController.ts new file mode 100644 index 0000000..a80aaad --- /dev/null +++ b/frontend/src/graph/graphAnalysisController.ts @@ -0,0 +1,202 @@ +import type { GraphAnalysisInput, GraphAnalysisResult } from "./graphAnalysis"; +import { + createGraphAnalysisWorkerRequest, + isGraphAnalysisWorkerResponse, + type GraphAnalysisWorkerResponse, +} from "./graphAnalysisWorker"; + +export type GraphAnalysisStatus = + | "not-run" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type GraphAnalysisState = { + status: GraphAnalysisStatus; + result: GraphAnalysisResult | null; + error: string | null; +}; + +export type GraphAnalysisWorkerLike = { + postMessage: (message: unknown) => void; + terminate: () => void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; +}; + +export type GraphAnalysisWorkerFactory = + () => GraphAnalysisWorkerLike; + +export type GraphAnalysisStateListener = ( + state: GraphAnalysisState, +) => void; + +const INITIAL_STATE: GraphAnalysisState = { + status: "not-run", + result: null, + error: null, +}; + +export class GraphAnalysisController { + private state: GraphAnalysisState = INITIAL_STATE; + private worker: GraphAnalysisWorkerLike | null = null; + private activeRequestId: string | null = null; + private nextRequestNumber = 1; + private disposed = false; + + private readonly createWorker: GraphAnalysisWorkerFactory; + private readonly onStateChange: GraphAnalysisStateListener; + + public constructor( + createWorker: GraphAnalysisWorkerFactory, + onStateChange: GraphAnalysisStateListener, + ) { + this.createWorker = createWorker; + this.onStateChange = onStateChange; + } + + public getState(): GraphAnalysisState { + return this.state; + } + + public run(input: GraphAnalysisInput): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + + const requestId = `graph-analysis-${this.nextRequestNumber}`; + this.nextRequestNumber += 1; + + const worker = this.createWorker(); + this.worker = worker; + this.activeRequestId = requestId; + + worker.onmessage = (event: MessageEvent) => { + this.handleWorkerMessage(requestId, event.data); + }; + + worker.onerror = (event: ErrorEvent) => { + this.handleWorkerError( + requestId, + event.message || "Graph analysis worker failed.", + ); + }; + + this.setState({ + status: "running", + result: null, + error: null, + }); + + worker.postMessage( + createGraphAnalysisWorkerRequest(requestId, input), + ); + } + + public cancel(): void { + if (this.disposed || this.state.status !== "running") { + return; + } + + this.terminateWorker(); + this.setState({ + status: "cancelled", + result: null, + error: null, + }); + } + + public reset(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.setState(INITIAL_STATE); + } + + public dispose(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.disposed = true; + } + + private handleWorkerMessage( + expectedRequestId: string, + value: unknown, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId || + !isGraphAnalysisWorkerResponse(value) || + value.requestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + + if (value.type === "success") { + this.setState({ + status: "completed", + result: value.result, + error: null, + }); + return; + } + + this.setState({ + status: "failed", + result: null, + error: value.error, + }); + } + + private handleWorkerError( + expectedRequestId: string, + error: string, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + this.setState({ + status: "failed", + result: null, + error, + }); + } + + private terminateWorker(): void { + if (this.worker !== null) { + this.worker.onmessage = null; + this.worker.onerror = null; + this.worker.terminate(); + } + + this.worker = null; + this.activeRequestId = null; + } + + private setState(state: GraphAnalysisState): void { + this.state = state; + this.onStateChange(state); + } +} + +export function isWorkerResponseForRequest( + response: GraphAnalysisWorkerResponse, + requestId: string, +): boolean { + return response.requestId === requestId; +} diff --git a/frontend/src/graph/graphAnalysisWorker.ts b/frontend/src/graph/graphAnalysisWorker.ts new file mode 100644 index 0000000..dc262ab --- /dev/null +++ b/frontend/src/graph/graphAnalysisWorker.ts @@ -0,0 +1,79 @@ +import type { + GraphAnalysisInput, + GraphAnalysisResult, +} from "./graphAnalysis"; + +export type GraphAnalysisWorkerRequest = { + type: "analyze"; + requestId: string; + input: GraphAnalysisInput; +}; + +export type GraphAnalysisWorkerSuccessResponse = { + type: "success"; + requestId: string; + result: GraphAnalysisResult; +}; + +export type GraphAnalysisWorkerErrorResponse = { + type: "error"; + requestId: string; + error: string; +}; + +export type GraphAnalysisWorkerResponse = + | GraphAnalysisWorkerSuccessResponse + | GraphAnalysisWorkerErrorResponse; + +export function createGraphAnalysisWorkerRequest( + requestId: string, + input: GraphAnalysisInput, +): GraphAnalysisWorkerRequest { + return { + type: "analyze", + requestId, + input, + }; +} + +export function isGraphAnalysisWorkerResponse( + value: unknown, +): value is GraphAnalysisWorkerResponse { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + if (typeof candidate.requestId !== "string") { + return false; + } + + if (candidate.type === "success") { + return isGraphAnalysisResult(candidate.result); + } + + if (candidate.type === "error") { + return typeof candidate.error === "string"; + } + + return false; +} + +function isGraphAnalysisResult( + value: unknown, +): value is GraphAnalysisResult { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + return ( + Array.isArray(candidate.terminalNodeIds) && + Array.isArray(candidate.components) && + Array.isArray(candidate.cyclicComponents) && + typeof candidate.statesInCyclicComponents === "number" && + typeof candidate.largestCyclicComponentSize === "number" + ); +} diff --git a/frontend/src/graph/pathSearch.test.ts b/frontend/src/graph/pathSearch.test.ts new file mode 100644 index 0000000..9f437db --- /dev/null +++ b/frontend/src/graph/pathSearch.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, it } from "vitest"; + +import { + findKShortestBoundedPaths, + type PathSearchEdge, + type PathSearchInput, +} from "./pathSearch"; + +function searchInput( + nodeIds: string[], + edges: PathSearchEdge[], + overrides: Partial = {}, +): PathSearchInput { + return { + nodeIds, + edges, + sourceNodeId: nodeIds[0] ?? "source", + targetNodeId: nodeIds[nodeIds.length - 1] ?? "target", + requestedPathCount: 5, + maximumVisitsPerState: 1, + constraints: {}, + ...overrides, + }; +} + +describe("findKShortestBoundedPaths", () => { + it("finds a direct path", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [{ id: "ab", source: "A", target: "B" }], + ), + ); + + expect(result.paths).toEqual([ + { startNodeId: "A", edgeIds: ["ab"] }, + ]); + expect(result.exhausted).toBe(true); + }); + + it("orders paths by increasing transition count", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C", "D"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "bd", source: "B", target: "D" }, + { id: "ad", source: "A", target: "D" }, + { id: "ac", source: "A", target: "C" }, + { id: "cd", source: "C", target: "D" }, + ], + { requestedPathCount: 3 }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["ad"], + ["ab", "bd"], + ["ac", "cd"], + ]); + }); + + it("uses input edge order for equal-length paths", () => { + const input = searchInput( + ["A", "B", "C", "D"], + [ + { id: "ac", source: "A", target: "C" }, + { id: "ab", source: "A", target: "B" }, + { id: "cd", source: "C", target: "D" }, + { id: "bd", source: "B", target: "D" }, + ], + { requestedPathCount: 2 }, + ); + + expect( + findKShortestBoundedPaths(input).paths.map((path) => path.edgeIds), + ).toEqual([ + ["ac", "cd"], + ["ab", "bd"], + ]); + expect(findKShortestBoundedPaths(input)).toEqual( + findKShortestBoundedPaths(input), + ); + }); + + it("preserves parallel edges as distinct paths", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [ + { id: "ab-primary", source: "A", target: "B" }, + { id: "ab-alternative", source: "A", target: "B" }, + ], + { requestedPathCount: 2 }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["ab-primary"], + ["ab-alternative"], + ]); + }); + + it("returns no path for disconnected states", () => { + const result = findKShortestBoundedPaths( + searchInput(["A", "B"], []), + ); + + expect(result.paths).toEqual([]); + expect(result.exhausted).toBe(true); + expect(result.stopReason).toBe("exhausted"); + }); + + it("returns fewer than K paths when the search space is exhausted", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [{ id: "ab", source: "A", target: "B" }], + { requestedPathCount: 10 }, + ), + ); + + expect(result.paths).toHaveLength(1); + expect(result.exhausted).toBe(true); + }); + + it("rejects revisiting a state when the visit limit is one", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ba", source: "B", target: "A" }, + { id: "ac", source: "A", target: "C" }, + ], + { + requestedPathCount: 5, + maximumVisitsPerState: 1, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([["ac"]]); + }); + + it("allows a bounded revisit when the visit limit is two", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ba", source: "B", target: "A" }, + { id: "ac", source: "A", target: "C" }, + ], + { + requestedPathCount: 2, + maximumVisitsPerState: 2, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["ac"], + ["ab", "ba", "ac"], + ]); + }); + + it("counts each self-loop traversal as another visit", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [ + { id: "loop", source: "A", target: "A" }, + { id: "ab", source: "A", target: "B" }, + ], + { + requestedPathCount: 2, + maximumVisitsPerState: 2, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["ab"], + ["loop", "ab"], + ]); + }); + + it("returns the zero-transition path when source equals target", () => { + const result = findKShortestBoundedPaths( + searchInput(["A"], [], { + sourceNodeId: "A", + targetNodeId: "A", + requestedPathCount: 1, + }), + ); + + expect(result.paths).toEqual([ + { startNodeId: "A", edgeIds: [] }, + ]); + expect(result.stopReason).toBe("requested-count-reached"); + }); + + it("finds returning paths after the zero-transition path", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ba", source: "B", target: "A" }, + { id: "ac", source: "A", target: "C" }, + { id: "ca", source: "C", target: "A" }, + ], + { + sourceNodeId: "A", + targetNodeId: "A", + requestedPathCount: 3, + maximumVisitsPerState: 2, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + [], + ["ab", "ba"], + ["ac", "ca"], + ]); + }); + + it("returns only the zero-transition path for equal endpoints with limit one", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ba", source: "B", target: "A" }, + ], + { + sourceNodeId: "A", + targetNodeId: "A", + requestedPathCount: 3, + maximumVisitsPerState: 1, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([[]]); + expect(result.exhausted).toBe(true); + }); + + it("finds a self-loop returning path when source equals target", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A"], + [{ id: "loop", source: "A", target: "A" }], + { + sourceNodeId: "A", + targetNodeId: "A", + requestedPathCount: 2, + maximumVisitsPerState: 2, + }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + [], + ["loop"], + ]); + }); + + it("reports when the expanded-candidate limit is reached", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C", "D"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ac", source: "A", target: "C" }, + { id: "bd", source: "B", target: "D" }, + { id: "cd", source: "C", target: "D" }, + ], + { requestedPathCount: 2 }, + ), + { maximumExpandedCandidates: 1 }, + ); + + expect(result.paths).toEqual([]); + expect(result.resourceLimitReached).toBe(true); + expect(result.stopReason).toBe("resource-limit-reached"); + }); + + it("reports when the queued-candidate limit is reached", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C", "D"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "ac", source: "A", target: "C" }, + { id: "ad", source: "A", target: "D" }, + { id: "bd", source: "B", target: "D" }, + { id: "cd", source: "C", target: "D" }, + ], + ), + { maximumQueuedCandidates: 1 }, + ); + + expect(result.resourceLimitReached).toBe(true); + expect(result.stopReason).toBe("resource-limit-reached"); + }); + + it("supports cancellation", () => { + let cancellationChecks = 0; + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B", "C"], + [ + { id: "ab", source: "A", target: "B" }, + { id: "bc", source: "B", target: "C" }, + ], + ), + { + shouldCancel: () => { + cancellationChecks += 1; + return cancellationChecks > 1; + }, + }, + ); + + expect(result.cancelled).toBe(true); + expect(result.stopReason).toBe("cancelled"); + }); + + it("accepts an empty future constraint model", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [{ id: "ab", source: "A", target: "B" }], + { constraints: { requiredTransitions: [] } }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([["ab"]]); + }); + + it("rejects non-empty transition constraints until matching is implemented", () => { + expect(() => + findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [{ id: "ab", source: "A", target: "B" }], + { + constraints: { + requiredTransitions: [{ transition: "Approve" }], + }, + }, + ), + ), + ).toThrow("Transition constraints are not implemented yet."); + }); + + it("rejects invalid search parameters", () => { + expect(() => + findKShortestBoundedPaths( + searchInput(["A", "B"], [], { requestedPathCount: 0 }), + ), + ).toThrow("Requested path count must be a positive integer."); + + expect(() => + findKShortestBoundedPaths( + searchInput(["A", "B"], [], { maximumVisitsPerState: 0 }), + ), + ).toThrow("Maximum visits per state must be a positive integer."); + }); + + it("rejects unknown states and invalid edge references", () => { + expect(() => + findKShortestBoundedPaths( + searchInput(["A", "B"], [], { sourceNodeId: "missing" }), + ), + ).toThrow("Source state missing does not exist."); + + expect(() => + findKShortestBoundedPaths( + searchInput( + ["A", "B"], + [{ id: "invalid", source: "A", target: "missing" }], + ), + ), + ).toThrow("Edge invalid references unknown target state missing."); + }); + + it("does not mutate the search input", () => { + const input = searchInput( + ["A", "B"], + [{ id: "ab", source: "A", target: "B" }], + ); + const original = structuredClone(input); + + findKShortestBoundedPaths(input); + + expect(input).toEqual(original); + }); + + + it("prunes large branches that cannot reach the target", () => { + const deadEndCount = 20_000; + const nodeIds = [ + "source", + "target", + ...Array.from({ length: deadEndCount }, (_, index) => `dead-${index}`), + ]; + const edges: PathSearchEdge[] = [ + { id: "direct", source: "source", target: "target" }, + ...Array.from({ length: deadEndCount }, (_, index) => ({ + id: `dead-edge-${index}`, + source: "source", + target: `dead-${index}`, + })), + ]; + + const result = findKShortestBoundedPaths( + searchInput(nodeIds, edges, { + sourceNodeId: "source", + targetNodeId: "target", + requestedPathCount: 1, + }), + { maximumQueuedCandidates: 10 }, + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([["direct"]]); + expect(result.resourceLimitReached).toBe(false); + expect(result.expandedCandidateCount).toBe(2); + }); + + it("uses reverse distance guidance while preserving shortest-first order", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "near", "far-1", "far-2", "target"], + [ + { id: "to-far", source: "source", target: "far-1" }, + { id: "far-step", source: "far-1", target: "far-2" }, + { id: "far-target", source: "far-2", target: "target" }, + { id: "to-near", source: "source", target: "near" }, + { id: "near-target", source: "near", target: "target" }, + ], + { requestedPathCount: 2 }, + ), + ); + + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["to-near", "near-target"], + ["to-far", "far-step", "far-target"], + ]); + }); + + it("handles a long linear graph", () => { + const nodeCount = 10_000; + const nodeIds = Array.from( + { length: nodeCount }, + (_, index) => String(index), + ); + const edges = Array.from( + { length: nodeCount - 1 }, + (_, index) => ({ + id: `edge-${index}`, + source: String(index), + target: String(index + 1), + }), + ); + + const result = findKShortestBoundedPaths( + searchInput(nodeIds, edges, { requestedPathCount: 1 }), + ); + + expect(result.paths).toHaveLength(1); + expect(result.paths[0].edgeIds).toHaveLength(nodeCount - 1); + }); +}); diff --git a/frontend/src/graph/pathSearch.ts b/frontend/src/graph/pathSearch.ts new file mode 100644 index 0000000..3ae9f73 --- /dev/null +++ b/frontend/src/graph/pathSearch.ts @@ -0,0 +1,516 @@ +export type PathSearchEdge = { + id: string; + source: string; + target: string; + transition?: string; + inputs?: unknown; + outputs?: unknown; +}; + +/** + * Reserved for ordered transition constraints in a future release. + * + * The current search accepts only an empty requiredTransitions array. Keeping + * the constraint model in the public API now avoids changing the search input + * and worker protocol when transition-name and partial-data matching are added. + */ +export type TransitionPattern = { + transition?: string; + inputs?: unknown; + outputs?: unknown; +}; + +export type PathConstraints = { + requiredTransitions?: TransitionPattern[]; +}; + +export type PathSearchInput = { + nodeIds: string[]; + edges: PathSearchEdge[]; + sourceNodeId: string; + targetNodeId: string; + requestedPathCount: number; + maximumVisitsPerState: number; + constraints?: PathConstraints; +}; + +export type BoundedPath = { + startNodeId: string; + edgeIds: string[]; +}; + +export type PathSearchStopReason = + | "requested-count-reached" + | "exhausted" + | "resource-limit-reached" + | "cancelled"; + +export type PathSearchResult = { + paths: BoundedPath[]; + exhausted: boolean; + resourceLimitReached: boolean; + cancelled: boolean; + stopReason: PathSearchStopReason; + expandedCandidateCount: number; + peakQueuedCandidateCount: number; +}; + +export type PathSearchOptions = { + maximumExpandedCandidates?: number; + maximumQueuedCandidates?: number; + shouldCancel?: () => boolean; +}; + +type NormalizedTopology = { + nodeIds: string[]; + outgoingEdgesByNodeId: Map; + incomingNodeIdsByNodeId: Map; +}; + +type ConstraintProgress = { + nextRequiredTransitionIndex: number; +}; + +type SearchCandidate = { + currentNodeId: string; + parent: SearchCandidate | null; + incomingEdgeId: string | null; + depth: number; + estimatedTotalCost: number; + constraintProgress: ConstraintProgress; + insertionSequence: number; +}; + +const DEFAULT_MAXIMUM_EXPANDED_CANDIDATES = 1_000_000; +const DEFAULT_MAXIMUM_QUEUED_CANDIDATES = 100_000; + +class CandidateMinHeap { + private readonly items: SearchCandidate[] = []; + + public get size(): number { + return this.items.length; + } + + public push(candidate: SearchCandidate): void { + this.items.push(candidate); + this.bubbleUp(this.items.length - 1); + } + + public pop(): SearchCandidate | undefined { + if (this.items.length === 0) { + return undefined; + } + + const first = this.items[0]; + const last = this.items.pop(); + + if (this.items.length > 0 && last !== undefined) { + this.items[0] = last; + this.bubbleDown(0); + } + + return first; + } + + private bubbleUp(startIndex: number): void { + let index = startIndex; + + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2); + + if (!hasHigherPriority(this.items[index], this.items[parentIndex])) { + return; + } + + [this.items[index], this.items[parentIndex]] = + [this.items[parentIndex], this.items[index]]; + index = parentIndex; + } + } + + private bubbleDown(startIndex: number): void { + let index = startIndex; + + while (true) { + const leftIndex = index * 2 + 1; + const rightIndex = leftIndex + 1; + let bestIndex = index; + + if ( + leftIndex < this.items.length && + hasHigherPriority(this.items[leftIndex], this.items[bestIndex]) + ) { + bestIndex = leftIndex; + } + + if ( + rightIndex < this.items.length && + hasHigherPriority(this.items[rightIndex], this.items[bestIndex]) + ) { + bestIndex = rightIndex; + } + + if (bestIndex === index) { + return; + } + + [this.items[index], this.items[bestIndex]] = + [this.items[bestIndex], this.items[index]]; + index = bestIndex; + } + } +} + +function hasHigherPriority( + left: SearchCandidate, + right: SearchCandidate, +): boolean { + if (left.estimatedTotalCost !== right.estimatedTotalCost) { + return left.estimatedTotalCost < right.estimatedTotalCost; + } + + if (left.depth !== right.depth) { + return left.depth < right.depth; + } + + return left.insertionSequence < right.insertionSequence; +} + +function buildTopology(input: PathSearchInput): NormalizedTopology { + const nodeIds: string[] = []; + const knownNodeIds = new Set(); + + for (const nodeId of input.nodeIds) { + if (!knownNodeIds.has(nodeId)) { + knownNodeIds.add(nodeId); + nodeIds.push(nodeId); + } + } + + if (!knownNodeIds.has(input.sourceNodeId)) { + throw new Error(`Source state ${input.sourceNodeId} does not exist.`); + } + + if (!knownNodeIds.has(input.targetNodeId)) { + throw new Error(`Target state ${input.targetNodeId} does not exist.`); + } + + if (!Number.isInteger(input.requestedPathCount) || input.requestedPathCount < 1) { + throw new Error("Requested path count must be a positive integer."); + } + + if ( + !Number.isInteger(input.maximumVisitsPerState) || + input.maximumVisitsPerState < 1 + ) { + throw new Error("Maximum visits per state must be a positive integer."); + } + + const requiredTransitions = input.constraints?.requiredTransitions ?? []; + + if (requiredTransitions.length > 0) { + throw new Error("Transition constraints are not implemented yet."); + } + + const outgoingEdgesByNodeId = new Map(); + const incomingNodeIdsByNodeId = new Map(); + + for (const nodeId of nodeIds) { + outgoingEdgesByNodeId.set(nodeId, []); + incomingNodeIdsByNodeId.set(nodeId, []); + } + + const knownEdgeIds = new Set(); + + for (const edge of input.edges) { + if (knownEdgeIds.has(edge.id)) { + throw new Error(`Duplicate edge ID: ${edge.id}.`); + } + + knownEdgeIds.add(edge.id); + + if (!knownNodeIds.has(edge.source)) { + throw new Error( + `Edge ${edge.id} references unknown source state ${edge.source}.`, + ); + } + + if (!knownNodeIds.has(edge.target)) { + throw new Error( + `Edge ${edge.id} references unknown target state ${edge.target}.`, + ); + } + + outgoingEdgesByNodeId.get(edge.source)?.push(edge); + incomingNodeIdsByNodeId.get(edge.target)?.push(edge.source); + } + + return { + nodeIds, + outgoingEdgesByNodeId, + incomingNodeIdsByNodeId, + }; +} + +function computeDistancesToTarget( + topology: NormalizedTopology, + targetNodeId: string, +): Map { + const distances = new Map([[targetNodeId, 0]]); + const queue: string[] = [targetNodeId]; + let queueIndex = 0; + + while (queueIndex < queue.length) { + const nodeId = queue[queueIndex]; + queueIndex += 1; + const nextDistance = (distances.get(nodeId) ?? 0) + 1; + const predecessors = + topology.incomingNodeIdsByNodeId.get(nodeId) ?? []; + + for (const predecessor of predecessors) { + if (distances.has(predecessor)) { + continue; + } + + distances.set(predecessor, nextDistance); + queue.push(predecessor); + } + } + + return distances; +} + +function validateResourceLimit( + value: number | undefined, + fallback: number, + label: string, +): number { + if (value === undefined) { + return fallback; + } + + if (!Number.isInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer.`); + } + + return value; +} + +function advanceConstraintProgress( + progress: ConstraintProgress, + _edge: PathSearchEdge, + _constraints: PathConstraints | undefined, +): ConstraintProgress | null { + // Future implementation: match the next required transition pattern against + // name, inputs, outputs, and other transition data using partial matching. + return progress; +} + +function countNodeVisits( + candidate: SearchCandidate, + nodeId: string, +): number { + let count = 0; + let current: SearchCandidate | null = candidate; + + while (current !== null) { + if (current.currentNodeId === nodeId) { + count += 1; + } + + current = current.parent; + } + + return count; +} + +function reconstructEdgeIds(candidate: SearchCandidate): string[] { + const edgeIds = new Array(candidate.depth); + let current: SearchCandidate | null = candidate; + let index = candidate.depth - 1; + + while (current !== null && current.incomingEdgeId !== null) { + edgeIds[index] = current.incomingEdgeId; + index -= 1; + current = current.parent; + } + + return edgeIds; +} + +function createPathKey(edgeIds: string[]): string { + return JSON.stringify(edgeIds); +} + +/** + * Finds up to K shortest unique paths with bounded state visits. + * + * All transitions currently have equal cost, so candidates are expanded in + * increasing transition count. Equal-length candidates follow input edge order. + * Path uniqueness is based on the ordered edge-ID sequence. + */ +export function findKShortestBoundedPaths( + input: PathSearchInput, + options: PathSearchOptions = {}, +): PathSearchResult { + const topology = buildTopology(input); + const distancesToTarget = computeDistancesToTarget( + topology, + input.targetNodeId, + ); + const maximumExpandedCandidates = validateResourceLimit( + options.maximumExpandedCandidates, + DEFAULT_MAXIMUM_EXPANDED_CANDIDATES, + "Maximum expanded candidates", + ); + const maximumQueuedCandidates = validateResourceLimit( + options.maximumQueuedCandidates, + DEFAULT_MAXIMUM_QUEUED_CANDIDATES, + "Maximum queued candidates", + ); + + const queue = new CandidateMinHeap(); + let nextInsertionSequence = 1; + let expandedCandidateCount = 0; + let peakQueuedCandidateCount = 1; + let resourceLimitReached = false; + let cancelled = false; + + queue.push({ + currentNodeId: input.sourceNodeId, + parent: null, + incomingEdgeId: null, + depth: 0, + estimatedTotalCost: + distancesToTarget.get(input.sourceNodeId) ?? Number.POSITIVE_INFINITY, + constraintProgress: { + nextRequiredTransitionIndex: 0, + }, + insertionSequence: 0, + }); + + const paths: BoundedPath[] = []; + const emittedPathKeys = new Set(); + + while (queue.size > 0 && paths.length < input.requestedPathCount) { + if (options.shouldCancel?.()) { + cancelled = true; + break; + } + + if (expandedCandidateCount >= maximumExpandedCandidates) { + resourceLimitReached = true; + break; + } + + const candidate = queue.pop(); + + if (candidate === undefined) { + break; + } + + expandedCandidateCount += 1; + const isZeroTransitionSourceTargetPath = + candidate.depth === 0 && + input.sourceNodeId === input.targetNodeId; + + if (candidate.currentNodeId === input.targetNodeId) { + const edgeIds = reconstructEdgeIds(candidate); + const pathKey = createPathKey(edgeIds); + + if (!emittedPathKeys.has(pathKey)) { + emittedPathKeys.add(pathKey); + paths.push({ + startNodeId: input.sourceNodeId, + edgeIds, + }); + } + + // The initial source-equals-target candidate must still be expanded so + // non-empty returning paths can be found. All other target arrivals end. + if (!isZeroTransitionSourceTargetPath) { + continue; + } + } + + const outgoingEdges = + topology.outgoingEdgesByNodeId.get(candidate.currentNodeId) ?? []; + + for (const edge of outgoingEdges) { + const remainingDistance = distancesToTarget.get(edge.target); + + if (remainingDistance === undefined) { + continue; + } + + if (queue.size >= maximumQueuedCandidates) { + resourceLimitReached = true; + break; + } + + const existingVisitCount = countNodeVisits(candidate, edge.target); + + if (existingVisitCount >= input.maximumVisitsPerState) { + continue; + } + + const nextConstraintProgress = advanceConstraintProgress( + candidate.constraintProgress, + edge, + input.constraints, + ); + + if (nextConstraintProgress === null) { + continue; + } + + queue.push({ + currentNodeId: edge.target, + parent: candidate, + incomingEdgeId: edge.id, + depth: candidate.depth + 1, + estimatedTotalCost: candidate.depth + 1 + remainingDistance, + constraintProgress: nextConstraintProgress, + insertionSequence: nextInsertionSequence, + }); + nextInsertionSequence += 1; + peakQueuedCandidateCount = Math.max( + peakQueuedCandidateCount, + queue.size, + ); + } + + if (resourceLimitReached) { + break; + } + } + + const requestedCountReached = paths.length >= input.requestedPathCount; + const exhausted = + !requestedCountReached && + !resourceLimitReached && + !cancelled && + queue.size === 0; + + let stopReason: PathSearchStopReason; + + if (cancelled) { + stopReason = "cancelled"; + } else if (resourceLimitReached) { + stopReason = "resource-limit-reached"; + } else if (requestedCountReached) { + stopReason = "requested-count-reached"; + } else { + stopReason = "exhausted"; + } + + return { + paths, + exhausted, + resourceLimitReached, + cancelled, + stopReason, + expandedCandidateCount, + peakQueuedCandidateCount, + }; +} diff --git a/frontend/src/graph/pathSearchController.test.ts b/frontend/src/graph/pathSearchController.test.ts new file mode 100644 index 0000000..7875158 --- /dev/null +++ b/frontend/src/graph/pathSearchController.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; + +import type { + PathSearchInput, + PathSearchResult, +} from "./pathSearch"; +import { + PathSearchController, + type PathSearchState, + type PathSearchWorkerLike, +} from "./pathSearchController"; + +class FakeWorker implements PathSearchWorkerLike { + public postedMessages: unknown[] = []; + public terminateCount = 0; + public onmessage: ((event: MessageEvent) => void) | null = null; + public onerror: ((event: ErrorEvent) => void) | null = null; + + public postMessage(message: unknown): void { + this.postedMessages.push(message); + } + + public terminate(): void { + this.terminateCount += 1; + } + + public respond(value: unknown): void { + this.onmessage?.({ data: value } as MessageEvent); + } + + public fail(message: string): void { + this.onerror?.({ message } as ErrorEvent); + } +} + +const INPUT: PathSearchInput = { + nodeIds: ["A", "B"], + edges: [ + { + id: "ab", + source: "A", + target: "B", + }, + ], + sourceNodeId: "A", + targetNodeId: "B", + requestedPathCount: 5, + maximumVisitsPerState: 1, + constraints: {}, +}; + +const RESULT: PathSearchResult = { + paths: [ + { + startNodeId: "A", + edgeIds: ["ab"], + }, + ], + exhausted: true, + resourceLimitReached: false, + cancelled: false, + stopReason: "exhausted", + expandedCandidateCount: 2, + peakQueuedCandidateCount: 1, +}; + +function setup() { + const workers: FakeWorker[] = []; + const states: PathSearchState[] = []; + const controller = new PathSearchController( + () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + (state) => states.push(state), + ); + + return { controller, workers, states }; +} + +function getRequestId(worker: FakeWorker): string { + const request = worker.postedMessages[0] as { + requestId: string; + }; + + return request.requestId; +} + +describe("PathSearchController", () => { + it("starts a search and posts a typed request", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(states).toEqual([ + { + status: "running", + result: null, + error: null, + }, + ]); + expect(workers[0].postedMessages).toEqual([ + { + type: "search", + requestId: "path-search-1", + input: INPUT, + }, + ]); + }); + + it("stores a successful result and terminates the worker", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].respond({ + type: "success", + requestId: getRequestId(workers[0]), + result: RESULT, + }); + + expect(controller.getState()).toEqual({ + status: "completed", + result: RESULT, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("stores a worker error response", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].respond({ + type: "error", + requestId: getRequestId(workers[0]), + error: "Search failed.", + }); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Search failed.", + }); + }); + + it("stores a worker runtime error", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].fail("Worker crashed."); + + expect(controller.getState()).toEqual({ + status: "failed", + result: null, + error: "Worker crashed.", + }); + }); + + it("cancels a running search", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.cancel(); + + expect(controller.getState()).toEqual({ + status: "cancelled", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("ignores cancellation when no search is running", () => { + const { controller, workers, states } = setup(); + + controller.cancel(); + + expect(workers).toEqual([]); + expect(states).toEqual([]); + expect(controller.getState().status).toBe("not-run"); + }); + + it("terminates the previous worker when search runs again", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.run(INPUT); + + expect(workers).toHaveLength(2); + expect(workers[0].terminateCount).toBe(1); + expect(workers[1].postedMessages[0]).toMatchObject({ + requestId: "path-search-2", + }); + }); + + it("ignores a stale response from an earlier request", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + const firstWorker = workers[0]; + const firstRequestId = getRequestId(firstWorker); + + controller.run(INPUT); + const secondWorker = workers[1]; + const secondRequestId = getRequestId(secondWorker); + + firstWorker.respond({ + type: "success", + requestId: firstRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("running"); + + secondWorker.respond({ + type: "success", + requestId: secondRequestId, + result: RESULT, + }); + + expect(controller.getState().status).toBe("completed"); + }); + + it("ignores malformed worker responses", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + workers[0].respond({ type: "success" }); + + expect(controller.getState().status).toBe("running"); + expect(workers[0].terminateCount).toBe(0); + }); + + it("resets state and terminates active work", () => { + const { controller, workers } = setup(); + + controller.run(INPUT); + controller.reset(); + + expect(controller.getState()).toEqual({ + status: "not-run", + result: null, + error: null, + }); + expect(workers[0].terminateCount).toBe(1); + }); + + it("disposes active work and ignores future searches", () => { + const { controller, workers, states } = setup(); + + controller.run(INPUT); + controller.dispose(); + controller.run(INPUT); + + expect(workers).toHaveLength(1); + expect(workers[0].terminateCount).toBe(1); + expect(states).toHaveLength(1); + }); +}); diff --git a/frontend/src/graph/pathSearchController.ts b/frontend/src/graph/pathSearchController.ts new file mode 100644 index 0000000..1c1c549 --- /dev/null +++ b/frontend/src/graph/pathSearchController.ts @@ -0,0 +1,197 @@ +import type { + PathSearchInput, + PathSearchResult, +} from "./pathSearch"; +import { + createPathSearchWorkerRequest, + isPathSearchWorkerResponse, +} from "./pathSearchWorker"; + +export type PathSearchStatus = + | "not-run" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type PathSearchState = { + status: PathSearchStatus; + result: PathSearchResult | null; + error: string | null; +}; + +export type PathSearchWorkerLike = { + postMessage: (message: unknown) => void; + terminate: () => void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; +}; + +export type PathSearchWorkerFactory = + () => PathSearchWorkerLike; + +export type PathSearchStateListener = ( + state: PathSearchState, +) => void; + +const INITIAL_STATE: PathSearchState = { + status: "not-run", + result: null, + error: null, +}; + +export class PathSearchController { + private state: PathSearchState = INITIAL_STATE; + private worker: PathSearchWorkerLike | null = null; + private activeRequestId: string | null = null; + private nextRequestNumber = 1; + private disposed = false; + + private readonly createWorker: PathSearchWorkerFactory; + private readonly onStateChange: PathSearchStateListener; + + public constructor( + createWorker: PathSearchWorkerFactory, + onStateChange: PathSearchStateListener, + ) { + this.createWorker = createWorker; + this.onStateChange = onStateChange; + } + + public getState(): PathSearchState { + return this.state; + } + + public run(input: PathSearchInput): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + + const requestId = `path-search-${this.nextRequestNumber}`; + this.nextRequestNumber += 1; + + const worker = this.createWorker(); + this.worker = worker; + this.activeRequestId = requestId; + + worker.onmessage = (event: MessageEvent) => { + this.handleWorkerMessage(requestId, event.data); + }; + + worker.onerror = (event: ErrorEvent) => { + this.handleWorkerError( + requestId, + event.message || "Path search worker failed.", + ); + }; + + this.setState({ + status: "running", + result: null, + error: null, + }); + + worker.postMessage( + createPathSearchWorkerRequest(requestId, input), + ); + } + + public cancel(): void { + if (this.disposed || this.state.status !== "running") { + return; + } + + this.terminateWorker(); + this.setState({ + status: "cancelled", + result: null, + error: null, + }); + } + + public reset(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.setState(INITIAL_STATE); + } + + public dispose(): void { + if (this.disposed) { + return; + } + + this.terminateWorker(); + this.disposed = true; + } + + private handleWorkerMessage( + expectedRequestId: string, + value: unknown, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId || + !isPathSearchWorkerResponse(value) || + value.requestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + + if (value.type === "success") { + this.setState({ + status: "completed", + result: value.result, + error: null, + }); + return; + } + + this.setState({ + status: "failed", + result: null, + error: value.error, + }); + } + + private handleWorkerError( + expectedRequestId: string, + error: string, + ): void { + if ( + this.disposed || + expectedRequestId !== this.activeRequestId + ) { + return; + } + + this.terminateWorker(); + this.setState({ + status: "failed", + result: null, + error, + }); + } + + private terminateWorker(): void { + if (this.worker !== null) { + this.worker.onmessage = null; + this.worker.onerror = null; + this.worker.terminate(); + } + + this.worker = null; + this.activeRequestId = null; + } + + private setState(state: PathSearchState): void { + this.state = state; + this.onStateChange(state); + } +} diff --git a/frontend/src/graph/pathSearchWorker.ts b/frontend/src/graph/pathSearchWorker.ts new file mode 100644 index 0000000..a6022c0 --- /dev/null +++ b/frontend/src/graph/pathSearchWorker.ts @@ -0,0 +1,90 @@ +import type { + PathSearchInput, + PathSearchResult, +} from "./pathSearch"; + +export type PathSearchWorkerRequest = { + type: "search"; + requestId: string; + input: PathSearchInput; +}; + +export type PathSearchWorkerSuccessResponse = { + type: "success"; + requestId: string; + result: PathSearchResult; +}; + +export type PathSearchWorkerErrorResponse = { + type: "error"; + requestId: string; + error: string; +}; + +export type PathSearchWorkerResponse = + | PathSearchWorkerSuccessResponse + | PathSearchWorkerErrorResponse; + +export function createPathSearchWorkerRequest( + requestId: string, + input: PathSearchInput, +): PathSearchWorkerRequest { + return { + type: "search", + requestId, + input, + }; +} + +export function isPathSearchWorkerResponse( + value: unknown, +): value is PathSearchWorkerResponse { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + if (typeof candidate.requestId !== "string") { + return false; + } + + if (candidate.type === "error") { + return typeof candidate.error === "string"; + } + + if (candidate.type === "success") { + return isPathSearchResult(candidate.result); + } + + return false; +} + +function isPathSearchResult( + value: unknown, +): value is PathSearchResult { + if (typeof value !== "object" || value === null) { + return false; + } + + const candidate = value as Record; + + return ( + Array.isArray(candidate.paths) && + typeof candidate.exhausted === "boolean" && + typeof candidate.resourceLimitReached === "boolean" && + typeof candidate.cancelled === "boolean" && + isStopReason(candidate.stopReason) && + typeof candidate.expandedCandidateCount === "number" && + typeof candidate.peakQueuedCandidateCount === "number" + ); +} + +function isStopReason(value: unknown): boolean { + return ( + value === "requested-count-reached" || + value === "exhausted" || + value === "resource-limit-reached" || + value === "cancelled" + ); +} diff --git a/frontend/src/graph/useGraphAnalysis.ts b/frontend/src/graph/useGraphAnalysis.ts new file mode 100644 index 0000000..5d1ad2b --- /dev/null +++ b/frontend/src/graph/useGraphAnalysis.ts @@ -0,0 +1,67 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import AnalysisWorker from "../workers/graphAnalysis.worker?worker&inline"; +import type { + GraphAnalysisInput, +} from "./graphAnalysis"; +import { + GraphAnalysisController, + type GraphAnalysisState, +} from "./graphAnalysisController"; + +const INITIAL_STATE: GraphAnalysisState = { + status: "not-run", + result: null, + error: null, +}; + +export type UseGraphAnalysisResult = GraphAnalysisState & { + run: (input: GraphAnalysisInput) => void; + cancel: () => void; + reset: () => void; +}; + +export function useGraphAnalysis(): UseGraphAnalysisResult { + const [state, setState] = + useState(INITIAL_STATE); + const controllerRef = + useRef(null); + + useEffect(() => { + const controller = new GraphAnalysisController( + () => new AnalysisWorker(), + setState, + ); + + controllerRef.current = controller; + + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, []); + + const run = useCallback((input: GraphAnalysisInput) => { + controllerRef.current?.run(input); + }, []); + + const cancel = useCallback(() => { + controllerRef.current?.cancel(); + }, []); + + const reset = useCallback(() => { + controllerRef.current?.reset(); + }, []); + + return { + ...state, + run, + cancel, + reset, + }; +} diff --git a/frontend/src/graph/usePathSearch.ts b/frontend/src/graph/usePathSearch.ts new file mode 100644 index 0000000..0f5468b --- /dev/null +++ b/frontend/src/graph/usePathSearch.ts @@ -0,0 +1,65 @@ +import { + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import PathSearchWorker from "../workers/pathSearch.worker?worker&inline"; +import type { PathSearchInput } from "./pathSearch"; +import { + PathSearchController, + type PathSearchState, +} from "./pathSearchController"; + +const INITIAL_STATE: PathSearchState = { + status: "not-run", + result: null, + error: null, +}; + +export type UsePathSearchResult = PathSearchState & { + run: (input: PathSearchInput) => void; + cancel: () => void; + reset: () => void; +}; + +export function usePathSearch(): UsePathSearchResult { + const [state, setState] = + useState(INITIAL_STATE); + const controllerRef = + useRef(null); + + useEffect(() => { + const controller = new PathSearchController( + () => new PathSearchWorker(), + setState, + ); + + controllerRef.current = controller; + + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, []); + + const run = useCallback((input: PathSearchInput) => { + controllerRef.current?.run(input); + }, []); + + const cancel = useCallback(() => { + controllerRef.current?.cancel(); + }, []); + + const reset = useCallback(() => { + controllerRef.current?.reset(); + }, []); + + return { + ...state, + run, + cancel, + reset, + }; +} diff --git a/frontend/src/workers/graphAnalysis.worker.ts b/frontend/src/workers/graphAnalysis.worker.ts new file mode 100644 index 0000000..a00fc65 --- /dev/null +++ b/frontend/src/workers/graphAnalysis.worker.ts @@ -0,0 +1,69 @@ +import { analyzeGraph } from "../graph/graphAnalysis"; +import type { + GraphAnalysisWorkerErrorResponse, + GraphAnalysisWorkerRequest, + GraphAnalysisWorkerResponse, + GraphAnalysisWorkerSuccessResponse, +} from "../graph/graphAnalysisWorker"; + +type GraphAnalysisWorkerScope = { + addEventListener: ( + type: "message", + listener: ( + event: MessageEvent, + ) => void, + ) => void; + + postMessage: ( + response: GraphAnalysisWorkerResponse, + ) => void; +}; + +const workerScope = + globalThis as unknown as GraphAnalysisWorkerScope; + +workerScope.addEventListener( + "message", + ( + event: MessageEvent, + ): void => { + const request = event.data; + + if (request.type !== "analyze") { + return; + } + + try { + const response: + GraphAnalysisWorkerSuccessResponse = { + type: "success", + requestId: request.requestId, + result: analyzeGraph(request.input), + }; + + workerScope.postMessage(response); + } catch (error: unknown) { + const response: + GraphAnalysisWorkerErrorResponse = { + type: "error", + requestId: request.requestId, + error: getErrorMessage(error), + }; + + workerScope.postMessage(response); + } + }, +); + +function getErrorMessage(error: unknown): string { + if ( + error instanceof Error && + error.message.length > 0 + ) { + return error.message; + } + + return "Graph analysis failed for an unknown reason."; +} + +export {}; diff --git a/frontend/src/workers/pathSearch.worker.ts b/frontend/src/workers/pathSearch.worker.ts new file mode 100644 index 0000000..c98c040 --- /dev/null +++ b/frontend/src/workers/pathSearch.worker.ts @@ -0,0 +1,59 @@ +import { findKShortestBoundedPaths } from "../graph/pathSearch"; +import type { + PathSearchWorkerErrorResponse, + PathSearchWorkerRequest, + PathSearchWorkerResponse, + PathSearchWorkerSuccessResponse, +} from "../graph/pathSearchWorker"; + +type PathSearchWorkerScope = { + addEventListener: ( + type: "message", + listener: ( + event: MessageEvent, + ) => void, + ) => void; + postMessage: (response: PathSearchWorkerResponse) => void; +}; + +const workerScope = + globalThis as unknown as PathSearchWorkerScope; + +workerScope.addEventListener( + "message", + (event: MessageEvent): void => { + const request = event.data; + + if (request.type !== "search") { + return; + } + + try { + const response: PathSearchWorkerSuccessResponse = { + type: "success", + requestId: request.requestId, + result: findKShortestBoundedPaths(request.input), + }; + + workerScope.postMessage(response); + } catch (error: unknown) { + const response: PathSearchWorkerErrorResponse = { + type: "error", + requestId: request.requestId, + error: getErrorMessage(error), + }; + + workerScope.postMessage(response); + } + }, +); + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + return "Path search failed for an unknown reason."; +} + +export {}; diff --git a/sample-data/synthetic.json b/sample-data/synthetic.json new file mode 100644 index 0000000..77fc63b --- /dev/null +++ b/sample-data/synthetic.json @@ -0,0 +1,1039 @@ +{ + "format": "ltsvisualizer", + "version": 1, + "type": "graph", + "metadata": { + "title": "SCC and terminal-state analysis sample", + "description": "Synthetic graph for testing terminal-state and strongly connected component analysis.", + "stateCount": 32, + "transitionCount": 41, + "expectedAnalysis": { + "terminalStateIds": [ + "terminal-success", + "terminal-timeout", + "terminal-rejected", + "isolated-terminal" + ], + "cyclicComponentSizesDescending": [ + 8, + 5, + 3, + 2, + 1 + ], + "statesInCyclicComponents": 19, + "largestCyclicComponentSize": 8 + } + }, + "nodes": [ + { + "id": "start", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "entry" + } + ], + "tokens": [ + { + "value": 0 + } + ] + } + }, + { + "id": "route-a", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "routing" + } + ], + "tokens": [ + { + "value": 1 + } + ] + } + }, + { + "id": "route-b", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "routing" + } + ], + "tokens": [ + { + "value": 2 + } + ] + } + }, + { + "id": "bridge", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "connector" + } + ], + "tokens": [ + { + "value": 3 + } + ] + } + }, + { + "id": "exit", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "connector" + } + ], + "tokens": [ + { + "value": 4 + } + ] + } + }, + { + "id": "terminal-success", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "terminal" + } + ], + "tokens": [ + { + "value": 100 + } + ] + } + }, + { + "id": "terminal-timeout", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "terminal" + } + ], + "tokens": [ + { + "value": 101 + } + ] + } + }, + { + "id": "terminal-rejected", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "terminal" + } + ], + "tokens": [ + { + "value": 102 + } + ] + } + }, + { + "id": "isolated-terminal", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "terminal" + } + ], + "tokens": [ + { + "value": 103 + } + ] + } + }, + { + "id": "A0", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 10 + } + ] + } + }, + { + "id": "A1", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 11 + } + ] + } + }, + { + "id": "A2", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 12 + } + ] + } + }, + { + "id": "A3", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 13 + } + ] + } + }, + { + "id": "A4", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 14 + } + ] + } + }, + { + "id": "A5", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 15 + } + ] + } + }, + { + "id": "A6", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 16 + } + ] + } + }, + { + "id": "A7", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-a" + } + ], + "tokens": [ + { + "value": 17 + } + ] + } + }, + { + "id": "B0", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-b" + } + ], + "tokens": [ + { + "value": 30 + } + ] + } + }, + { + "id": "B1", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-b" + } + ], + "tokens": [ + { + "value": 31 + } + ] + } + }, + { + "id": "B2", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-b" + } + ], + "tokens": [ + { + "value": 32 + } + ] + } + }, + { + "id": "B3", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-b" + } + ], + "tokens": [ + { + "value": 33 + } + ] + } + }, + { + "id": "B4", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-b" + } + ], + "tokens": [ + { + "value": 34 + } + ] + } + }, + { + "id": "C0", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-c" + } + ], + "tokens": [ + { + "value": 50 + } + ] + } + }, + { + "id": "C1", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-c" + } + ], + "tokens": [ + { + "value": 51 + } + ] + } + }, + { + "id": "C2", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-c" + } + ], + "tokens": [ + { + "value": 52 + } + ] + } + }, + { + "id": "D0", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-d" + } + ], + "tokens": [ + { + "value": 60 + } + ] + } + }, + { + "id": "D1", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "cycle-d" + } + ], + "tokens": [ + { + "value": 61 + } + ] + } + }, + { + "id": "SELF", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "self-loop" + } + ], + "tokens": [ + { + "value": 70 + } + ] + } + }, + { + "id": "tail-0", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "tail" + } + ], + "tokens": [ + { + "value": 80 + } + ] + } + }, + { + "id": "tail-1", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "tail" + } + ], + "tokens": [ + { + "value": 81 + } + ] + } + }, + { + "id": "tail-2", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "tail" + } + ], + "tokens": [ + { + "value": 82 + } + ] + } + }, + { + "id": "tail-3", + "marking_raw": null, + "marking": { + "phase": [ + { + "name": "tail" + } + ], + "tokens": [ + { + "value": 83 + } + ] + } + } + ], + "edges": [ + { + "id": "edge-001", + "source": "start", + "target": "route-a", + "transition": "Initialize", + "color": "darkorange", + "inputs_raw": null, + "inputs": { + "request": { + "id": 1 + } + }, + "outputs_raw": null, + "outputs": { + "route": [ + { + "id": 1 + } + ] + } + }, + { + "id": "edge-002", + "source": "route-a", + "target": "A0", + "transition": "EnterLargeCycle", + "color": null, + "inputs_raw": null, + "inputs": { + "route": { + "id": 1 + } + }, + "outputs_raw": null, + "outputs": { + "cycleA": [ + { + "id": 1 + } + ] + } + }, + { + "id": "edge-003", + "source": "start", + "target": "route-b", + "transition": "AlternativeRoute", + "color": null, + "inputs_raw": null, + "inputs": { + "request": { + "id": 2 + } + }, + "outputs_raw": null, + "outputs": { + "route": [ + { + "id": 2 + } + ] + } + }, + { + "id": "edge-004", + "source": "route-b", + "target": "C0", + "transition": "EnterSmallCycle", + "color": null, + "inputs_raw": null, + "inputs": { + "route": { + "id": 2 + } + }, + "outputs_raw": null, + "outputs": { + "cycleC": [ + { + "id": 2 + } + ] + } + }, + { + "id": "edge-005", + "source": "A0", + "target": "A1", + "transition": "A_Step_0", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-006", + "source": "A1", + "target": "A2", + "transition": "A_Step_1", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-007", + "source": "A2", + "target": "A3", + "transition": "A_Step_2", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-008", + "source": "A3", + "target": "A4", + "transition": "A_Step_3", + "color": "darkorange", + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-009", + "source": "A4", + "target": "A5", + "transition": "A_Step_4", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-010", + "source": "A5", + "target": "A6", + "transition": "A_Step_5", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-011", + "source": "A6", + "target": "A7", + "transition": "A_Step_6", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-012", + "source": "A7", + "target": "A0", + "transition": "A_Step_7", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-013", + "source": "A1", + "target": "A5", + "transition": "A_FastForward", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-014", + "source": "A6", + "target": "A2", + "transition": "A_Retry", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-015", + "source": "A4", + "target": "A0", + "transition": "A_Reset", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-016", + "source": "A7", + "target": "bridge", + "transition": "LeaveCycleA", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-017", + "source": "bridge", + "target": "B0", + "transition": "EnterCycleB", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-018", + "source": "B0", + "target": "B1", + "transition": "B_Step_0", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-019", + "source": "B1", + "target": "B2", + "transition": "B_Step_1", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-020", + "source": "B2", + "target": "B3", + "transition": "B_Step_2", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-021", + "source": "B3", + "target": "B4", + "transition": "B_Step_3", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-022", + "source": "B4", + "target": "B0", + "transition": "B_Step_4", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-023", + "source": "B3", + "target": "B1", + "transition": "B_Rework", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-024", + "source": "B4", + "target": "D0", + "transition": "EscalateToCycleD", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-025", + "source": "B2", + "target": "terminal-timeout", + "transition": "Timeout", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-026", + "source": "C0", + "target": "C1", + "transition": "C_Forward", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-027", + "source": "C1", + "target": "C2", + "transition": "C_Validate", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-028", + "source": "C2", + "target": "C0", + "transition": "C_Retry", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-029", + "source": "C2", + "target": "B0", + "transition": "PromoteToCycleB", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-030", + "source": "C1", + "target": "terminal-rejected", + "transition": "Reject", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-031", + "source": "D0", + "target": "D1", + "transition": "D_ProcessPrimary", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-032", + "source": "D0", + "target": "D1", + "transition": "D_ProcessAlternative", + "color": "darkorange", + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-033", + "source": "D1", + "target": "D0", + "transition": "D_Retry", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-034", + "source": "D1", + "target": "SELF", + "transition": "EnterSelfLoop", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-035", + "source": "SELF", + "target": "SELF", + "transition": "Heartbeat", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-036", + "source": "SELF", + "target": "exit", + "transition": "LeaveHeartbeat", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-037", + "source": "exit", + "target": "tail-0", + "transition": "BeginTail", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-038", + "source": "tail-0", + "target": "tail-1", + "transition": "TailStep1", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-039", + "source": "tail-1", + "target": "tail-2", + "transition": "TailStep2", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-040", + "source": "tail-2", + "target": "tail-3", + "transition": "TailStep3", + "color": null, + "inputs_raw": null, + "inputs": null, + "outputs_raw": null, + "outputs": null + }, + { + "id": "edge-041", + "source": "tail-3", + "target": "terminal-success", + "transition": "Complete", + "color": "darkorange", + "inputs_raw": null, + "inputs": { + "completion": { + "status": "ok" + } + }, + "outputs_raw": null, + "outputs": {} + } + ] +} \ No newline at end of file