diff --git a/frontend/src/App.css b/frontend/src/App.css index 1257f6d..b2deee5 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -154,14 +154,21 @@ button.active { } .workspace { + --side-panel-width: 480px; + --side-panel-rail-width: 48px; + --side-panel-resizer-width: 10px; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); - gap: 14px; + grid-template-columns: minmax(0, 1fr) var(--side-panel-resizer-width) var(--side-panel-width); + gap: 0; flex: 1; min-height: 0; padding: 14px; } +.workspace.side-panel-collapsed { + grid-template-columns: minmax(0, 1fr) var(--side-panel-rail-width); +} + .graph-panel { position: relative; min-width: 0; @@ -206,14 +213,109 @@ button.active { height: calc(100vh - 205px); min-height: 520px; min-width: 0; - padding: 18px; - overflow: auto; + padding: 0; + overflow: hidden; border: 1px solid #cbd5e1; border-radius: 10px; background: #ffffff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); } +.side-panel-content { + height: calc(100% - 49px); + min-width: 0; + padding: 14px 18px 18px; + overflow: auto; +} + +.side-panel-shell-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 48px; + padding: 8px 10px 8px 14px; + border-bottom: 1px solid #e2e8f0; +} + +.side-panel-shell-title { + color: #334155; + font-size: 13px; + font-weight: 750; +} + +.side-panel-collapse-button, +.side-panel-expand-button { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + padding: 0; + border-color: #cbd5e1; + background: #ffffff; + color: #334155; + font-size: 16px; + line-height: 1; +} + +.side-panel-collapse-button:hover:not(:disabled), +.side-panel-expand-button:hover:not(:disabled) { + border-color: #93c5fd; + background: #eff6ff; + color: #1d4ed8; +} + +.workspace.side-panel-collapsed .inspector { + min-width: var(--side-panel-rail-width); +} + +.workspace.side-panel-collapsed .side-panel-shell-header { + justify-content: center; + padding: 8px; + border-bottom: 0; +} + +.side-panel-resizer { + position: relative; + z-index: 5; + min-width: 0; + cursor: col-resize; + touch-action: none; + outline: none; +} + +.side-panel-resizer::before { + position: absolute; + top: 10px; + bottom: 10px; + left: 4px; + width: 2px; + border-radius: 999px; + background: #cbd5e1; + content: ""; + transition: left 120ms ease, width 120ms ease, background-color 120ms ease; +} + +.side-panel-resizer:hover::before, +.side-panel-resizer:focus-visible::before { + left: 3px; + width: 4px; + background: #2563eb; +} + +.side-panel-resizer:focus-visible { + box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.28); +} + +body.resizing-side-panel { + cursor: col-resize; + user-select: none; +} + +body.resizing-side-panel iframe, +body.resizing-side-panel canvas { + pointer-events: none; +} + .inspector-heading { display: flex; align-items: flex-start; @@ -303,11 +405,22 @@ button.active { align-items: flex-start; } - .workspace { + .workspace, + .workspace.side-panel-collapsed { grid-template-columns: 1fr; + gap: 14px; overflow: visible; } + .side-panel-resizer { + display: none; + } + + .workspace.side-panel-collapsed .inspector { + min-height: 50px; + height: 50px; + } + .app { overflow: visible; } @@ -712,3 +825,401 @@ button.active { text-decoration: underline; } + + +.declare-builder { display: grid; gap: 10px; margin-top: 8px; padding-top: 12px; border-top: 1px solid #e2e8f0; } +.declare-builder-heading, .declare-constraint-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.declare-builder-heading > div { display: grid; gap: 2px; } +.declare-builder-heading strong { font-size: 13px; } +.declare-builder-heading span, .declare-empty, .declare-description { color: #64748b; font-size: 11px; } +.declare-builder-heading button, .declare-constraint-toolbar button { padding: 6px 8px; font-size: 11px; } +.declare-empty { margin: 0; padding: 9px; border: 1px dashed #cbd5e1; border-radius: 7px; } +.declare-constraint-list { display: grid; gap: 9px; } +.declare-constraint-card { display: grid; gap: 8px; padding: 10px; border: 1px solid #cbd5e1; border-radius: 8px; background: #f8fafc; } +.declare-constraint-card.invalid { border-color: #fca5a5; background: #fff7f7; } +.declare-constraint-card > label { display: grid; gap: 5px; color: #475569; font-size: 11px; font-weight: 700; } +.declare-constraint-card select, .declare-constraint-card input:not([type="checkbox"]) { width: 100%; min-width: 0; padding: 7px 8px; border: 1px solid #cbd5e1; border-radius: 6px; background: #fff; } +.declare-constraint-toolbar label { display: inline-flex; align-items: center; gap: 6px; color: #334155; font-size: 11px; font-weight: 700; } +.declare-constraint-toolbar input { width: auto; } +.declare-description { margin: 0; line-height: 1.4; } +.declare-errors { margin: 0; padding-left: 18px; color: #b91c1c; font-size: 10px; line-height: 1.4; } + +.transition-picker-label { + position: relative; + z-index: 1; +} +.transition-picker-label:focus-within { + z-index: 5; +} +.transition-picker { + position: relative; + display: block; +} +.transition-picker-list { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + right: 0; + left: 0; + display: grid; + max-height: 260px; + overflow-y: auto; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #ffffff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} +.transition-picker-list button { + display: grid; + gap: 2px; + width: 100%; + padding: 8px 9px; + border: 0; + border-bottom: 1px solid #e2e8f0; + border-radius: 0; + text-align: left; +} +.transition-picker-list button:last-child { + border-bottom: 0; +} +.transition-picker-list button:hover, +.transition-picker-list button.active { + background: #eff6ff; + color: #1d4ed8; +} +.transition-picker-list button span { + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 700; +} +.transition-picker-list button small { + color: #64748b; + font-size: 9px; + font-weight: 600; +} +.transition-picker-empty { + padding: 10px; + color: #64748b; + font-size: 10px; + font-weight: 500; + line-height: 1.4; +} + + +.transition-data-browser { + position: relative; + display: grid; + gap: 5px; + padding: 8px; + border: 1px solid #e2e8f0; + border-radius: 7px; + background: #ffffff; +} +.transition-data-browser > label { + display: grid; + gap: 5px; + color: #475569; + font-size: 11px; + font-weight: 700; +} +.transition-data-picker { + position: relative; +} +.transition-data-picker input { + width: 100%; + min-width: 0; + padding: 7px 8px; + border: 1px solid #cbd5e1; + border-radius: 6px; + background: #ffffff; +} +.transition-data-picker input:focus { + border-color: #2563eb; + outline: none; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.13); +} +.transition-data-picker-list { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + right: 0; + left: 0; + display: grid; + max-height: 260px; + overflow-y: auto; + border: 1px solid #cbd5e1; + border-radius: 7px; + background: #ffffff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); +} +.transition-data-picker-list button { + display: grid; + gap: 2px; + width: 100%; + padding: 8px 9px; + border: 0; + border-bottom: 1px solid #e2e8f0; + border-radius: 0; + text-align: left; +} +.transition-data-picker-list button:last-child { + border-bottom: 0; +} +.transition-data-picker-list button:hover, +.transition-data-picker-list button.active { + background: #dbeafe; + color: #1d4ed8; +} +.transition-data-picker-list button span { + overflow-wrap: anywhere; + font-size: 11px; + font-weight: 700; +} +.transition-data-picker-list button small { + color: #64748b; + font-size: 9px; + font-weight: 600; +} +.transition-data-picker-note, +.transition-data-picker-selection { + margin: 0; + color: #64748b; + font-size: 10px; + line-height: 1.4; +} +.transition-data-picker-selection code { + color: #334155; + overflow-wrap: anywhere; +} + +.transition-condition-editor { + display: grid; + gap: 12px; + padding: 12px; + border: 1px solid #dbe4f0; + border-radius: 8px; + background: #f8fafc; +} + +.transition-condition-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.transition-condition-heading strong { + color: #334155; + font-size: 11px; +} + +.transition-condition-heading span { + flex: 0 0 auto; + padding: 3px 7px; + border-radius: 999px; + background: #e2e8f0; + color: #475569; + font-size: 9px; + font-weight: 700; +} + +.transition-condition-list { + display: grid; + gap: 7px; + margin: 0; + padding-left: 20px; +} + +.transition-condition-list li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.transition-condition-list code { + overflow-wrap: anywhere; + color: #334155; + font-size: 10px; + line-height: 1.45; +} + +.transition-condition-list button, +.transition-condition-draft button { + padding: 6px 8px; + font-size: 10px; +} + +.transition-condition-editor .transition-data-browser { + padding: 0; + border: 0; + background: transparent; +} + +.transition-condition-editor .transition-data-picker-selection { + display: none; +} + +.transition-condition-draft { + display: grid; + gap: 12px; + padding-top: 2px; +} + +.transition-condition-draft fieldset { + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.transition-condition-draft legend { + width: 100%; + margin-bottom: 8px; + padding: 0 0 6px; + border-bottom: 1px solid #e2e8f0; + color: #334155; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.transition-condition-draft label { + display: grid; + gap: 5px; + min-width: 0; + color: #475569; + font-size: 10px; + font-weight: 700; +} + +.transition-condition-draft input, +.transition-condition-draft select { + width: 100%; + min-width: 0; +} + +.transition-array-levels { + display: grid; + gap: 10px; +} + +.transition-array-level { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 8px; + align-items: start; +} + +.transition-array-level-number { + display: grid; + place-items: center; + width: 24px; + height: 24px; + margin-top: 21px; + border-radius: 999px; + background: #dbeafe; + color: #1d4ed8; + font-size: 11px; + font-weight: 800; +} + +.transition-array-level-controls { + display: grid; + gap: 7px; + min-width: 0; +} + +.transition-array-index { + grid-template-columns: minmax(0, 1fr); +} + +.transition-array-index input { + max-width: 140px; +} + +.transition-comparison-grid { + display: grid; + grid-template-columns: minmax(92px, 0.7fr) minmax(130px, 1fr); + gap: 8px; +} + +.transition-condition-value { + grid-column: 1 / -1; +} + +.transition-condition-add { + width: 100%; + min-height: 34px; + border-color: #93c5fd; + background: #eff6ff; + color: #1d4ed8; +} + +.transition-condition-add:hover:not(:disabled) { + border-color: #60a5fa; + background: #dbeafe; +} + +.transition-condition-warning, +.transition-condition-error { + margin: 0; + font-size: 10px; + line-height: 1.4; +} + +.transition-condition-warning { + color: #92400e; +} + +.transition-condition-error { + color: #b91c1c; +} + +@media (max-width: 520px) { + .transition-comparison-grid { + grid-template-columns: 1fr; + } + + .transition-condition-value { + grid-column: auto; + } +} + + +.path-search-checkbox { + display: inline-flex !important; + align-items: center; + gap: 8px; + margin-top: 2px; + color: #334155 !important; + font-size: 12px !important; + font-weight: 700; + cursor: pointer; +} + +.path-search-checkbox input { + width: auto; + min-width: auto; + margin: 0; + accent-color: #2563eb; +} + +.path-search-checkbox:has(input:disabled) { + cursor: not-allowed; + opacity: 0.6; +} + +.path-search-help { + margin: -2px 0 2px; + color: #64748b; + font-size: 11px; + line-height: 1.45; +} + +.computed-path-show small { + max-width: 58%; + line-height: 1.35; +} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f9423f..1ed0c42 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,9 @@ import { type ChangeEvent, + type CSSProperties, type FormEvent, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, useEffect, useRef, useState, @@ -8,6 +11,9 @@ import { import cytoscape from "cytoscape"; import "./App.css"; import JsonViewer, { type JsonValue } from "./components/JsonViewer"; +import DeclareConstraintBuilder from "./graph/DeclareConstraintBuilder"; +import type { DeclareConstraint } from "./graph/declareConstraints"; +import { validateExecutableDeclareConstraint } from "./graph/declareMonitorFactory"; import PathSelectionControls, { type PathSelectionMode, } from "./components/PathSelectionControls"; @@ -33,6 +39,17 @@ import { useGraphAnalysis } from "./graph/useGraphAnalysis"; import { usePathSearch } from "./graph/usePathSearch"; import type { BoundedPath } from "./graph/pathSearch"; import type { StronglyConnectedComponent } from "./graph/graphAnalysis"; +import { buildTransitionCatalogue } from "./graph/transitionCatalog"; +import { buildTransitionDataCatalogue } from "./graph/transitionDataCatalogue"; +import { + SIDE_PANEL_COLLAPSED_STORAGE_KEY, + SIDE_PANEL_WIDTH_STORAGE_KEY, + clampSidePanelWidth, + getKeyboardResizedSidePanelWidth, + getSidePanelMaximumWidth, + parseStoredSidePanelCollapsed, + parseStoredSidePanelWidth, +} from "./ui/sidePanelState"; interface GraphNode { id: string; @@ -81,6 +98,12 @@ interface GraphViewSnapshot { const TERMINAL_PAGE_SIZE = 100; const SCC_PAGE_SIZE = 100; +function readStoredSidePanelWidth(): number { + return parseStoredSidePanelWidth( + window.localStorage.getItem(SIDE_PANEL_WIDTH_STORAGE_KEY), + window.innerWidth, + ); +} function App() { const graphContainer = useRef(null); @@ -110,12 +133,20 @@ function App() { const [selectedPath, setSelectedPath] = useState(null); const [sidePanelMode, setSidePanelMode] = useState("inspector"); + const [sidePanelWidth, setSidePanelWidth] = useState(readStoredSidePanelWidth); + const [isSidePanelCollapsed, setIsSidePanelCollapsed] = useState( + () => parseStoredSidePanelCollapsed( + window.localStorage.getItem(SIDE_PANEL_COLLAPSED_STORAGE_KEY), + ), + ); 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 [requireConstraintExercise, setRequireConstraintExercise] = useState(true); + const [declareConstraints, setDeclareConstraints] = useState([]); const [shownSearchPathIndex, setShownSearchPathIndex] = useState(null); const [computedPathViewActive, setComputedPathViewActive] = useState(false); const [focusedComputedStepKey, setFocusedComputedStepKey] = useState(null); @@ -131,6 +162,51 @@ function App() { const [selectedCyclicComponentId, setSelectedCyclicComponentId] = useState(null); + function beginSidePanelResize(event: ReactPointerEvent) { + if (isSidePanelCollapsed || event.button !== 0) return; + event.preventDefault(); + + const startX = event.clientX; + const startWidth = sidePanelWidth; + document.body.classList.add("resizing-side-panel"); + + const handlePointerMove = (pointerEvent: globalThis.PointerEvent) => { + setSidePanelWidth( + clampSidePanelWidth( + startWidth + startX - pointerEvent.clientX, + window.innerWidth, + ), + ); + }; + const finishResize = () => { + document.body.classList.remove("resizing-side-panel"); + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", finishResize); + window.removeEventListener("pointercancel", finishResize); + window.requestAnimationFrame(() => cyRef.current?.resize()); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", finishResize); + window.addEventListener("pointercancel", finishResize); + } + + function resizeSidePanelWithKeyboard(event: KeyboardEvent) { + const nextWidth = getKeyboardResizedSidePanelWidth( + sidePanelWidth, + event.key, + event.shiftKey, + window.innerWidth, + ); + if (nextWidth === null) return; + event.preventDefault(); + setSidePanelWidth(nextWidth); + } + + function toggleSidePanel() { + setIsSidePanelCollapsed((collapsed) => !collapsed); + } + function updatePathMode(mode: PathSelectionMode) { pathModeRef.current = mode; setPathMode(mode); @@ -141,6 +217,20 @@ function App() { setSelectedPath(path); } + function invalidatePathSearchResults() { + if (pathSearch.status !== "not-run") { + pathSearch.reset(); + setShownSearchPathIndex(null); + setFocusedComputedStepKey(null); + setComputedPathViewActive(false); + } + } + + function changeDeclareConstraints(constraints: DeclareConstraint[]) { + invalidatePathSearchResults(); + setDeclareConstraints(constraints); + } + function makeNodeInspector(node: cytoscape.NodeSingular): InspectorInfo { const marking = node.data("marking") as JsonValue | null | undefined; @@ -320,7 +410,10 @@ function App() { }); } - function showPathContext(path: SelectedPath) { + function showPathContext( + path: SelectedPath, + positionCompletePath = false, + ) { const graph = graphRef.current; const cy = cyRef.current; if (!graph || !cy) return; @@ -328,9 +421,11 @@ function App() { const resolvedPath = resolvePath(graph, path); const selectedEdges = resolvedPath.edges; const pathNodeIds = resolvedPath.nodeIds; + const uniquePathNodeIds = [...new Set(pathNodeIds)]; + const pathNodeIdSet = new Set(uniquePathNodeIds); const endNodeId = resolvedPath.endNodeId; const outgoingEdges = getCandidateEdges(graph, path); - const requiredNodeIds = new Set(pathNodeIds); + const requiredNodeIds = new Set(uniquePathNodeIds); const requiredEdgeIds = new Set([ ...path.edgeIds, ...outgoingEdges.map((edge) => edge.id), @@ -349,40 +444,103 @@ function App() { .remove(); cy.endBatch(); - const missingNodeIds = new Set( - [...requiredNodeIds].filter((nodeId) => cy.getElementById(nodeId).empty()) + const missingPathNodeIds = uniquePathNodeIds.filter( + (nodeId) => cy.getElementById(nodeId).empty(), + ); + const missingCandidateNodeIds = [ + ...new Set(outgoingEdges.map((edge) => edge.target)), + ].filter( + (nodeId) => + !pathNodeIdSet.has(nodeId) && cy.getElementById(nodeId).empty(), ); + const missingNodeIds = new Set([ + ...missingPathNodeIds, + ...missingCandidateNodeIds, + ]); const missingEdges = [...selectedEdges, ...outgoingEdges].filter( - (edge) => cy.getElementById(edge.id).empty() + (edge) => cy.getElementById(edge.id).empty(), ); - const endpoint = cy.getElementById(endNodeId); - const endpointPosition = endpoint.nonempty() - ? endpoint.position() - : { x: 160, y: 160 }; - const targetNodeIds = [...new Set(outgoingEdges.map((edge) => edge.target))]; + const firstVisiblePathNode = uniquePathNodeIds + .map((nodeId) => cy.getElementById(nodeId)) + .find((node) => node.nonempty()); + const pathOrigin = firstVisiblePathNode?.position() ?? { x: 160, y: 160 }; cy.startBatch(); if (missingNodeIds.size > 0) { - const nodeElements = buildElements(graph, missingNodeIds, [], showTransitionLabels); - cy.add(nodeElements); + cy.add( + buildElements( + graph, + missingNodeIds, + [], + showTransitionLabels, + ), + ); - [...missingNodeIds].forEach((nodeId) => { - const targetIndex = Math.max(0, targetNodeIds.indexOf(nodeId)); + if (positionCompletePath) { + // Computed paths can contain many nodes that were not part of the + // previous neighborhood. Give those path nodes deterministic, + // non-overlapping positions in traversal order. Existing visible path + // nodes retain their coordinates, preserving the user's orientation. + const columnCount = Math.max( + 1, + Math.ceil(Math.sqrt(uniquePathNodeIds.length)), + ); + const horizontalSpacing = 230; + const verticalSpacing = 155; + + missingPathNodeIds.forEach((nodeId) => { + const pathIndex = uniquePathNodeIds.indexOf(nodeId); + const row = Math.floor(pathIndex / columnCount); + const positionInRow = pathIndex % columnCount; + const column = + row % 2 === 0 + ? positionInRow + : columnCount - positionInRow - 1; + + cy.getElementById(nodeId).position({ + x: pathOrigin.x + column * horizontalSpacing, + y: pathOrigin.y + row * verticalSpacing, + }); + }); + } + + const endpoint = cy.getElementById(endNodeId); + const endpointPosition = endpoint.nonempty() + ? endpoint.position() + : pathOrigin; + + missingCandidateNodeIds.forEach((nodeId, index) => { const verticalOffset = - targetIndex - (Math.max(targetNodeIds.length, 1) - 1) / 2; + index - (Math.max(missingCandidateNodeIds.length, 1) - 1) / 2; cy.getElementById(nodeId).position({ x: endpointPosition.x + 230, y: endpointPosition.y + verticalOffset * 135, }); }); + + // Preserve the original manual path-selection behavior. During manual + // extension only successor candidates are normally missing. + if (!positionCompletePath) { + const targetNodeIds = [ + ...new Set(outgoingEdges.map((edge) => edge.target)), + ]; + + missingPathNodeIds.forEach((nodeId) => { + const targetIndex = Math.max(0, targetNodeIds.indexOf(nodeId)); + const verticalOffset = + targetIndex - (Math.max(targetNodeIds.length, 1) - 1) / 2; + cy.getElementById(nodeId).position({ + x: endpointPosition.x + 230, + y: endpointPosition.y + verticalOffset * 135, + }); + }); + } } if (missingEdges.length > 0) { - cy.add( - buildElements(graph, new Set(), missingEdges, true) - ); + cy.add(buildElements(graph, new Set(), missingEdges, true)); } cy.nodes().unlock(); @@ -769,8 +927,31 @@ function App() { 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`); + const enabledConstraints = declareConstraints.filter( + (constraint) => constraint.enabled, + ); + if (!targetNodeId && enabledConstraints.length === 0) { + setStatus( + "Enter a target state or configure at least one enabled constraint.", + ); + return; + } + if ( + targetNodeId && + !graph.nodes.some((node) => node.id === targetNodeId) + ) { + setStatus(`Target state ${targetNodeId} was not found`); + return; + } + const constraintErrors = enabledConstraints + .filter((constraint) => constraint.enabled) + .flatMap((constraint) => + validateExecutableDeclareConstraint(constraint).map( + (error) => `${constraint.id}: ${error}`, + ), + ); + if (constraintErrors.length > 0) { + setStatus(`Fix the Declare constraints: ${constraintErrors.join(" ")}`); return; } setShownSearchPathIndex(null); @@ -780,14 +961,27 @@ function App() { id: edge.id, source: edge.source, target: edge.target, + transition: edge.transition, + color: edge.color, + inputs_raw: edge.inputs_raw, + inputs: edge.inputs, + outputs_raw: edge.outputs_raw, + outputs: edge.outputs, })), sourceNodeId, - targetNodeId, + ...(targetNodeId + ? { targetNodeId, endpointMode: "specific-target" as const } + : { endpointMode: "constraint-satisfaction" as const }), requestedPathCount, maximumVisitsPerState, - constraints: {}, + requireConstraintExercise, + constraints: { declare: declareConstraints }, }); - setStatus(`Searching for up to ${requestedPathCount} paths from ${sourceNodeId} to ${targetNodeId}`); + setStatus( + targetNodeId + ? `Searching for up to ${requestedPathCount} paths from ${sourceNodeId} to ${targetNodeId}` + : `Searching for up to ${requestedPathCount} constraint-satisfying paths from ${sourceNodeId}`, + ); } function captureGraphViewBeforeComputedPath() { @@ -867,7 +1061,7 @@ function App() { setSelectedCyclicComponentId(null); updateSelectedPath(selected); updatePathMode("idle"); - showPathContext(selected); + showPathContext(selected, true); setShownSearchPathIndex(index); setFocusedComputedStepKey(null); setComputedPathViewActive(true); @@ -895,6 +1089,7 @@ function App() { graphAnalysis.reset(); pathSearch.reset(); + setDeclareConstraints([]); setShownSearchPathIndex(null); setFocusedComputedStepKey(null); setComputedPathViewActive(false); @@ -933,7 +1128,8 @@ function App() { ? "0" : graph.nodes[0].id; setPathSearchSource(defaultPathState); - setPathSearchTarget(defaultPathState); + setPathSearchTarget(""); + setRequireConstraintExercise(true); if (importedPath) { const resolved = resolvePath(graph, importedPath); @@ -1236,6 +1432,32 @@ function App() { }; }, [pathMode, selectedPath?.edgeIds.length]); + useEffect(() => { + window.localStorage.setItem( + SIDE_PANEL_WIDTH_STORAGE_KEY, + String(Math.round(sidePanelWidth)), + ); + }, [sidePanelWidth]); + + useEffect(() => { + window.localStorage.setItem( + SIDE_PANEL_COLLAPSED_STORAGE_KEY, + String(isSidePanelCollapsed), + ); + const frame = window.requestAnimationFrame(() => cyRef.current?.resize()); + return () => window.cancelAnimationFrame(frame); + }, [isSidePanelCollapsed]); + + useEffect(() => { + const handleWindowResize = () => { + setSidePanelWidth((current) => + clampSidePanelWidth(current, window.innerWidth), + ); + }; + window.addEventListener("resize", handleWindowResize); + return () => window.removeEventListener("resize", handleWindowResize); + }, []); + useEffect(() => { const container = graphContainer.current; const cy = cyRef.current; @@ -1251,6 +1473,16 @@ function App() { return () => observer.disconnect(); }, []); + const transitionOptions = buildTransitionCatalogue( + graphRef.current?.edges.map((edge) => edge.transition) ?? [], + ); + const transitionDataCatalogue = buildTransitionDataCatalogue( + graphRef.current?.edges.map((edge) => ({ + transition: edge.transition, + inputs: edge.inputs, + outputs: edge.outputs, + })) ?? [], + ); const visibleInspector = pinnedInspector ?? inspectorInfo; const selectedPathEdges = selectedPath?.edgeIds ?? []; const selectedPathEndNodeId = (() => { @@ -1593,7 +1825,14 @@ function App() { /> -
+
{!graphLoaded && (
@@ -1604,7 +1843,47 @@ function App() {
-
)} + + )}
@@ -2160,3 +2484,4 @@ function App() { } export default App; + diff --git a/frontend/src/graph/DeclareConstraintBuilder.tsx b/frontend/src/graph/DeclareConstraintBuilder.tsx new file mode 100644 index 0000000..a5520da --- /dev/null +++ b/frontend/src/graph/DeclareConstraintBuilder.tsx @@ -0,0 +1,324 @@ +import { + DECLARE_TEMPLATE_DEFINITIONS, + getDeclareTemplateDefinition, + type DeclareConstraint, + type DeclarePredicateGroup, + type DeclarePredicateRole, + type DeclareTemplateId, +} from "./declareConstraints"; +import { validateExecutableDeclareConstraint } from "./declareMonitorFactory"; +import TransitionPicker from "./TransitionPicker"; +import TransitionConditionEditor from "./TransitionConditionEditor"; +import type { TransitionOption } from "./transitionCatalog"; +import type { TransitionDataCatalogue } from "./transitionDataCatalogue"; + +type Props = { + constraints: DeclareConstraint[]; + disabled: boolean; + transitionOptions: readonly TransitionOption[]; + transitionDataCatalogue: TransitionDataCatalogue; + onChange: (constraints: DeclareConstraint[]) => void; +}; + +function group(value = ""): DeclarePredicateGroup { + return { + relation: "or", + predicates: [{ transition: { operator: "equals", value } }], + }; +} + +function transitionValue( + constraint: DeclareConstraint, + role: DeclarePredicateRole, +): string { + return constraint[role]?.predicates[0]?.transition?.value ?? ""; +} + +function transitionCondition( + constraint: DeclareConstraint, + role: DeclarePredicateRole, +) { + return constraint[role]?.predicates[0]?.condition; +} + +export default function DeclareConstraintBuilder({ + constraints, + disabled, + transitionOptions, + transitionDataCatalogue, + onChange, +}: Props) { + function update( + id: string, + change: (constraint: DeclareConstraint) => DeclareConstraint, + ) { + onChange( + constraints.map((constraint) => + constraint.id === id ? change(constraint) : constraint, + ), + ); + } + + function add() { + let sequence = constraints.length + 1; + while (constraints.some((constraint) => constraint.id === `constraint-${sequence}`)) { + sequence += 1; + } + onChange([ + ...constraints, + { + id: `constraint-${sequence}`, + template: "response", + enabled: true, + activation: group(), + target: group(), + }, + ]); + } + + function changeTemplate(id: string, template: DeclareTemplateId) { + update(id, (constraint) => { + const definition = getDeclareTemplateDefinition(template); + return { + id: constraint.id, + template, + enabled: constraint.enabled, + activation: constraint.activation ?? group(), + target: definition.requiredRoles.includes("target") + ? constraint.target ?? group() + : undefined, + between: definition.requiredRoles.includes("between") + ? constraint.between ?? group() + : undefined, + count: definition.supportsCount ? constraint.count ?? 1 : undefined, + }; + }); + } + + function changeTransition( + id: string, + role: DeclarePredicateRole, + value: string, + ) { + update(id, (constraint) => { + const currentGroup = constraint[role] ?? group(); + const currentPredicate = currentGroup.predicates[0] ?? {}; + + return { + ...constraint, + [role]: { + ...currentGroup, + predicates: [ + { + ...currentPredicate, + transition: { operator: "equals", value }, + }, + ...currentGroup.predicates.slice(1), + ], + }, + }; + }); + } + + function changeCondition( + id: string, + role: DeclarePredicateRole, + condition: import("./transitionConditions").TransitionCondition | undefined, + ) { + update(id, (constraint) => { + const currentGroup = constraint[role] ?? group(); + const currentPredicate = currentGroup.predicates[0] ?? {}; + const nextPredicate = { ...currentPredicate }; + + if (condition === undefined) { + delete nextPredicate.condition; + } else { + nextPredicate.condition = condition; + } + + return { + ...constraint, + [role]: { + ...currentGroup, + predicates: [nextPredicate, ...currentGroup.predicates.slice(1)], + }, + }; + }); + } + + return ( +
+
+
+ Declare constraints + {constraints.length} configured +
+ +
+ {constraints.length === 0 ? ( +

+ No constraints. All bounded paths are eligible. +

+ ) : ( +
+ {constraints.map((constraint) => { + const definition = getDeclareTemplateDefinition(constraint.template); + const errors = validateExecutableDeclareConstraint(constraint); + return ( +
0 + ? "declare-constraint-card invalid" + : "declare-constraint-card" + } + > +
+ + +
+ + + changeTransition(constraint.id, "activation", value) + } + /> + + changeCondition(constraint.id, "activation", condition) + } + /> + {definition.requiredRoles.includes("target") && ( + <> + + changeTransition(constraint.id, "target", value) + } + /> + + changeCondition(constraint.id, "target", condition) + } + /> + + )} + {definition.requiredRoles.includes("between") && ( + <> + + changeTransition(constraint.id, "between", value) + } + /> + + changeCondition(constraint.id, "between", condition) + } + /> + + )} + {definition.supportsCount && ( + + )} +

{definition.description}

+ {constraint.enabled && errors.length > 0 && ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/graph/TransitionConditionEditor.tsx b/frontend/src/graph/TransitionConditionEditor.tsx new file mode 100644 index 0000000..ddcc478 --- /dev/null +++ b/frontend/src/graph/TransitionConditionEditor.tsx @@ -0,0 +1,288 @@ +import { useMemo, useState } from "react"; +import TransitionDataFieldPicker from "./TransitionDataFieldPicker"; +import type { ComparisonOperator, TransitionCondition } from "./transitionConditions"; +import type { TransitionDataCatalogue, TransitionDataField } from "./transitionDataCatalogue"; +import { + countArrayLevels, + createDefaultArrayAccesses, + createFlatTransitionCondition, + formatConfiguredPath, + inferConditionValueType, + parseConditionValue, + readFlatTransitionConditions, + type ArrayAccess, + type ConditionValueType, + type FlatTransitionCondition, +} from "./transitionConditionEditorModel"; + +const OPERATORS: readonly ComparisonOperator[] = [ + "=", "!=", "<", "<=", ">", ">=", "exists", "does-not-exist", +]; + +type Props = { + catalogue: TransitionDataCatalogue; + transitionName: string; + condition: TransitionCondition | undefined; + disabled?: boolean; + onChange: (condition: TransitionCondition | undefined) => void; +}; + +function formatOperation(condition: FlatTransitionCondition): string { + if (condition.operator === "exists") return "exists"; + if (condition.operator === "does-not-exist") return "does not exist"; + return `${condition.operator} ${condition.value === null ? "null" : JSON.stringify(condition.value)}`; +} + +function formatCondition(condition: FlatTransitionCondition): string { + const path = formatConfiguredPath( + condition.source, + condition.path, + condition.arrayAccesses, + ); + const containsLevels = condition.arrayAccesses.filter( + (access) => access.mode === "contains-item", + ).length; + const qualifier = containsLevels > 0 + ? ` (${containsLevels} existential array level${containsLevels === 1 ? "" : "s"})` + : ""; + return `${path} ${formatOperation(condition)}${qualifier}`; +} + +export default function TransitionConditionEditor({ + catalogue, + transitionName, + condition, + disabled = false, + onChange, +}: Props) { + const existingConditions = useMemo( + () => readFlatTransitionConditions(condition), + [condition], + ); + const [selectedField, setSelectedField] = useState(null); + const [operator, setOperator] = useState("="); + const [valueType, setValueType] = useState("string"); + const [valueText, setValueText] = useState(""); + const [arrayAccesses, setArrayAccesses] = useState([]); + const [error, setError] = useState(null); + + function selectField(field: TransitionDataField) { + setSelectedField(field); + const inferredType = inferConditionValueType(field.valueTypes); + setValueType(inferredType); + setValueText(inferredType === "boolean" ? "true" : ""); + setArrayAccesses(createDefaultArrayAccesses(field.path)); + setError(null); + } + + function updateArrayAccess(level: number, access: ArrayAccess) { + setArrayAccesses((current) => + current.map((item, index) => index === level ? access : item), + ); + setError(null); + } + + function addCondition() { + if (!selectedField || existingConditions === null) return; + try { + const value = operator === "exists" || operator === "does-not-exist" + ? undefined + : parseConditionValue(valueType, valueText); + const next: FlatTransitionCondition = { + source: selectedField.source, + path: [...selectedField.path], + arrayAccesses: [...arrayAccesses], + operator, + ...(value !== undefined ? { value } : {}), + }; + onChange(createFlatTransitionCondition([...existingConditions, next])); + setSelectedField(null); + setArrayAccesses([]); + setValueText(""); + setError(null); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Invalid value."); + } + } + + function removeCondition(index: number) { + if (existingConditions === null) return; + onChange(createFlatTransitionCondition( + existingConditions.filter((_, conditionIndex) => conditionIndex !== index), + )); + } + + const requiresValue = operator !== "exists" && operator !== "does-not-exist"; + const arrayLevelCount = selectedField === null ? 0 : countArrayLevels(selectedField.path); + + return ( +
+
+ Match ALL data conditions + {existingConditions?.length ?? 0} configured +
+ {existingConditions === null ? ( +

+ This predicate contains an advanced condition that the flat editor cannot modify. The condition is preserved. +

+ ) : ( + <> + {existingConditions.length > 0 && ( +
    + {existingConditions.map((item, index) => ( +
  1. + {formatCondition(item)} + +
  2. + ))} +
+ )} + + {selectedField && ( +
+ {arrayAccesses.length > 0 && ( +
+ Array traversal +
+ {arrayAccesses.map((access, level) => ( +
+ +
+ + {access.mode === "indexed-item" && ( + + )} +
+
+ ))} +
+
+ )} + +
+ Comparison +
+ + {requiresValue && ( + + )} + {requiresValue && valueType === "boolean" ? ( + + ) : requiresValue && valueType !== "null" ? ( + + ) : null} +
+
+ + +
+ )} + {error &&

{error}

} + + )} +
+ ); +} + diff --git a/frontend/src/graph/TransitionDataFieldPicker.tsx b/frontend/src/graph/TransitionDataFieldPicker.tsx new file mode 100644 index 0000000..dfe0d94 --- /dev/null +++ b/frontend/src/graph/TransitionDataFieldPicker.tsx @@ -0,0 +1,198 @@ +import { + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; + +import { + getTransitionDataFields, + type TransitionDataCatalogue, + type TransitionDataField, +} from "./transitionDataCatalogue"; + +type Props = { + catalogue: TransitionDataCatalogue; + transitionName: string; + disabled?: boolean; + label?: string; + selectedField?: TransitionDataField | null; + onSelect?: (field: TransitionDataField) => void; +}; + +const MAX_VISIBLE_OPTIONS = 100; + +function searchText(field: TransitionDataField): string { + return [ + field.displayPath, + field.source, + ...field.valueTypes, + ] + .join(" ") + .toLocaleLowerCase(); +} + +export default function TransitionDataFieldPicker({ + catalogue, + transitionName, + disabled = false, + label = "Available data fields", + selectedField: controlledSelectedField, + onSelect, +}: Props) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [internalSelectedField, setInternalSelectedField] = + useState(null); + const selectedField = controlledSelectedField === undefined + ? internalSelectedField + : controlledSelectedField; + const containerRef = useRef(null); + + const availableFields = useMemo( + () => getTransitionDataFields(catalogue, transitionName), + [catalogue, transitionName], + ); + + const filteredFields = useMemo(() => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + const matches = normalizedQuery + ? availableFields.filter((field) => + searchText(field).includes(normalizedQuery), + ) + : [...availableFields]; + + return matches.slice(0, MAX_VISIBLE_OPTIONS); + }, [availableFields, query]); + + useEffect(() => { + setQuery(""); + setOpen(false); + setActiveIndex(0); + setInternalSelectedField(null); + }, [transitionName, catalogue]); + + useEffect(() => { + function handlePointerDown(event: PointerEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + } + + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, []); + + function select(field: TransitionDataField) { + setInternalSelectedField(field); + onSelect?.(field); + setQuery(field.displayPath); + setOpen(false); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + return; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setOpen(true); + setActiveIndex((index) => + Math.min(index + 1, Math.max(0, filteredFields.length - 1)), + ); + return; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setOpen(true); + setActiveIndex((index) => Math.max(0, index - 1)); + return; + } + + if (event.key === "Enter" && open && filteredFields[activeIndex]) { + event.preventDefault(); + select(filteredFields[activeIndex]); + } + } + + const usingGraphWideFallback = + transitionName.length > 0 && + catalogue.fieldsByTransition[transitionName] === undefined; + + return ( +
+ + {usingGraphWideFallback && ( +

+ This transition name is not in the loaded graph. Showing graph-wide + data fields. +

+ )} + {selectedField && ( +

+ Selected field: {selectedField.displayPath} +

+ )} +
+ ); +} diff --git a/frontend/src/graph/TransitionPicker.tsx b/frontend/src/graph/TransitionPicker.tsx new file mode 100644 index 0000000..e507e3e --- /dev/null +++ b/frontend/src/graph/TransitionPicker.tsx @@ -0,0 +1,130 @@ +import { + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { + filterTransitionOptions, + type TransitionOption, +} from "./transitionCatalog"; + +type Props = { + label: string; + value: string; + options: readonly TransitionOption[]; + disabled: boolean; + onChange: (value: string) => void; +}; + +export default function TransitionPicker({ + label, + value, + options, + disabled, + onChange, +}: Props) { + const inputId = useId(); + const listboxId = useId(); + const closeTimer = useRef(null); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const matches = useMemo( + () => filterTransitionOptions(options, value), + [options, value], + ); + + function openList() { + if (disabled) return; + if (closeTimer.current !== null) window.clearTimeout(closeTimer.current); + setOpen(true); + setActiveIndex(0); + } + + function select(name: string) { + onChange(name); + setOpen(false); + setActiveIndex(0); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + setOpen(false); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + if (!open) openList(); + else setActiveIndex((index) => Math.min(matches.length - 1, index + 1)); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + if (!open) openList(); + else setActiveIndex((index) => Math.max(0, index - 1)); + return; + } + if (event.key === "Enter" && open && matches[activeIndex]) { + event.preventDefault(); + select(matches[activeIndex].name); + } + } + + return ( + + ); +} diff --git a/frontend/src/graph/declareBasicMonitors.test.ts b/frontend/src/graph/declareBasicMonitors.test.ts new file mode 100644 index 0000000..aad835a --- /dev/null +++ b/frontend/src/graph/declareBasicMonitors.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { + createAtLeastMonitor, + createAtMostMonitor, + createChoiceMonitor, + createEndMonitor, + createExactlyConsecutiveMonitor, + createExactlyMonitor, + createExclusiveChoiceMonitor, + createInitMonitor, +} from "./declareBasicMonitors"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; + +const group = (name: string) => ({ + relation: "or" as const, + predicates: [ + { transition: { operator: "equals" as const, value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + transitions: string[], +): State { + return transitions.reduce( + (state, transition) => monitor.advance(state, { transition }), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + transitions: string[], +) { + return monitor.status(run(monitor, transitions)); +} + +describe("cardinality monitors", () => { + it("implements at least N with an accepting saturated count", () => { + const monitor = createAtLeastMonitor(group("A"), 2); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + expect(run(monitor, ["A", "A", "A"]).count).toBe(2); + }); + + it("implements at most N and prunes after the limit", () => { + const monitor = createAtMostMonitor(group("A"), 1); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements exactly N with pending and violated states", () => { + const monitor = createExactlyMonitor(group("A"), 2); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["A", "A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires exactly N occurrences to form one consecutive run", () => { + const monitor = createExactlyConsecutiveMonitor(group("A"), 2); + expect(status(monitor, ["X", "A", "A", "X"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["A", "X", "A"])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, ["A", "A", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("supports a zero cardinality", () => { + expect(status(createExactlyMonitor(group("A"), 0), [])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(createAtMostMonitor(group("A"), 0), ["A"])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(createExactlyConsecutiveMonitor(group("A"), 0), [])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("rejects invalid counts", () => { + expect(() => createAtLeastMonitor(group("A"), -1)).toThrow( + "Count must be a non-negative integer.", + ); + expect(() => createExactlyMonitor(group("A"), 1.5)).toThrow( + "Count must be a non-negative integer.", + ); + }); +}); + +describe("position monitors", () => { + it("requires Init to match the first transition", () => { + const monitor = createInitMonitor(group("A")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X"])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, ["X", "A"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires End to match the final transition", () => { + const monitor = createEndMonitor(group("A")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A", "X"])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, ["X", "A"])).toEqual({ + viable: true, + accepting: true, + }); + }); +}); + +describe("choice monitors", () => { + it("accepts Choice when either side occurs", () => { + const monitor = createChoiceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["B"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "B"])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("accepts Exclusive Choice only when exactly one side occurs", () => { + const monitor = createExclusiveChoiceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, ["A"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["B"])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, ["A", "B"])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("treats one edge matching both exclusive-choice sides as a violation", () => { + const dataGroup = { + relation: "or" as const, + predicates: [ + { + condition: { + type: "source" as const, + source: "inputs" as const, + condition: { + type: "comparison" as const, + path: ["value"], + operator: "=" as const, + value: 1, + }, + }, + }, + ], + }; + const monitor = createExclusiveChoiceMonitor(group("A"), dataGroup); + const edge: DeclareTransition = { transition: "A", inputs: { value: 1 } }; + const state = monitor.advance(monitor.initialState(), edge); + expect(monitor.status(state)).toEqual({ viable: false, accepting: false }); + }); +}); diff --git a/frontend/src/graph/declareBasicMonitors.ts b/frontend/src/graph/declareBasicMonitors.ts new file mode 100644 index 0000000..8fd0b04 --- /dev/null +++ b/frontend/src/graph/declareBasicMonitors.ts @@ -0,0 +1,250 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; + +type CountState = { + count: number; + violated: boolean; +}; + +type ConsecutiveCountState = CountState & { + runStarted: boolean; + runEnded: boolean; +}; + +type PositionState = { + seenTransition: boolean; + matches: boolean; + violated: boolean; +}; + +type ChoiceState = { + seenActivation: boolean; + seenTarget: boolean; + violated: boolean; +}; + +function matches(group: DeclarePredicateGroup, edge: DeclareTransition): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function validateCount(count: number): void { + if (!Number.isInteger(count) || count < 0) { + throw new Error("Count must be a non-negative integer."); + } +} + +export function createAtLeastMonitor( + activation: DeclarePredicateGroup, + minimum: number, +): DeclareMonitor { + validateCount(minimum); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + count: Math.min(minimum, state.count + (matches(activation, edge) ? 1 : 0)), + violated: state.violated, + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count >= minimum, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAtMostMonitor( + activation: DeclarePredicateGroup, + maximum: number, +): DeclareMonitor { + validateCount(maximum); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => { + if (state.violated || !matches(activation, edge)) { + return state; + } + const count = state.count + 1; + return { count, violated: count > maximum }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExactlyMonitor( + activation: DeclarePredicateGroup, + expected: number, +): DeclareMonitor { + validateCount(expected); + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => { + if (state.violated || !matches(activation, edge)) { + return state; + } + const count = state.count + 1; + return { count, violated: count > expected }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count === expected, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExactlyConsecutiveMonitor( + activation: DeclarePredicateGroup, + expected: number, +): DeclareMonitor { + validateCount(expected); + return { + initialState: () => ({ + count: 0, + runStarted: false, + runEnded: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + const isActivation = matches(activation, edge); + if (isActivation) { + if (state.runEnded || expected === 0) { + return { ...state, violated: true }; + } + const count = state.count + 1; + return { + count, + runStarted: true, + runEnded: false, + violated: count > expected, + }; + } + return state.runStarted + ? { ...state, runEnded: true } + : state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count === expected, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createInitMonitor( + activation: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenTransition: false, + matches: false, + violated: false, + }), + advance: (state, edge) => { + if (state.seenTransition) { + return state; + } + const firstMatches = matches(activation, edge); + return { + seenTransition: true, + matches: firstMatches, + violated: !firstMatches, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: state.seenTransition && state.matches && !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createEndMonitor( + activation: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenTransition: false, + matches: false, + violated: false, + }), + advance: (_state, edge) => ({ + seenTransition: true, + matches: matches(activation, edge), + violated: false, + }), + status: (state) => ({ + viable: true, + accepting: state.seenTransition && state.matches, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChoiceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenActivation: false, + seenTarget: false, + violated: false, + }), + advance: (state, edge) => ({ + seenActivation: state.seenActivation || matches(activation, edge), + seenTarget: state.seenTarget || matches(target, edge), + violated: state.violated, + }), + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && (state.seenActivation || state.seenTarget), + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createExclusiveChoiceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, +): DeclareMonitor { + return { + initialState: () => ({ + seenActivation: false, + seenTarget: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + const seenActivation = state.seenActivation || matches(activation, edge); + const seenTarget = state.seenTarget || matches(target, edge); + return { + seenActivation, + seenTarget, + violated: seenActivation && seenTarget, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && state.seenActivation !== state.seenTarget, + }), + stateKey: canonicalMonitorStateKey, + }; +} diff --git a/frontend/src/graph/declareConstraints.test.ts b/frontend/src/graph/declareConstraints.test.ts new file mode 100644 index 0000000..e914107 --- /dev/null +++ b/frontend/src/graph/declareConstraints.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { + DECLARE_TEMPLATE_DEFINITIONS, + getDeclareTemplateDefinition, + validateDeclareConstraint, + type DeclareConstraint, + type DeclarePredicateGroup, +} from "./declareConstraints"; + +const predicate = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: name }, + }, + ], +}); + +describe("Declare template registry", () => { + it("contains the complete planned template catalog with unique IDs", () => { + expect(DECLARE_TEMPLATE_DEFINITIONS).toHaveLength(30); + const ids = DECLARE_TEMPLATE_DEFINITIONS.map((definition) => definition.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("records roles and capabilities for representative templates", () => { + expect(getDeclareTemplateDefinition("response")).toMatchObject({ + category: "future", + requiredRoles: ["activation", "target"], + supportsCount: false, + supportsCorrelation: true, + }); + expect(getDeclareTemplateDefinition("at-least")).toMatchObject({ + category: "cardinality", + requiredRoles: ["activation"], + supportsCount: true, + supportsCorrelation: false, + }); + expect(getDeclareTemplateDefinition("alternate-succession")).toMatchObject({ + category: "bidirectional", + requiredRoles: ["activation", "target", "between"], + }); + }); +}); + +describe("validateDeclareConstraint", () => { + it("accepts a data-aware response constraint", () => { + const constraint: DeclareConstraint = { + id: "same-request-completes", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "SubmitRequest" }, + captures: [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + ], + }, + ], + }, + target: predicate("CompleteRequest"), + correlation: { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["request", "id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([]); + }); + + it("requires the roles defined by the template", () => { + const constraint: DeclareConstraint = { + id: "incomplete-response", + template: "response", + enabled: true, + activation: predicate("A"), + }; + + expect(validateDeclareConstraint(constraint)).toContain("target is required."); + }); + + it("requires and validates counts for cardinality templates", () => { + const missingCount: DeclareConstraint = { + id: "at-least-a", + template: "at-least", + enabled: true, + activation: predicate("A"), + }; + const invalidCount: DeclareConstraint = { + ...missingCount, + count: -1, + }; + const validCount: DeclareConstraint = { + ...missingCount, + count: 2, + }; + + expect(validateDeclareConstraint(missingCount)).toContain( + "Count must be a non-negative integer.", + ); + expect(validateDeclareConstraint(invalidCount)).toContain( + "Count must be a non-negative integer.", + ); + expect(validateDeclareConstraint(validCount)).toEqual([]); + }); + + it("rejects roles and options not used by a template", () => { + const constraint: DeclareConstraint = { + id: "bad-init", + template: "init", + enabled: true, + activation: predicate("A"), + target: predicate("B"), + count: 1, + correlation: { + type: "comparison", + left: { kind: "literal", value: 1 }, + operator: "=", + right: { kind: "literal", value: 1 }, + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([ + "Init does not support a count.", + "Init does not support correlation conditions.", + "Init does not use target.", + ]); + }); + + it("rejects empty predicates and captures outside activation", () => { + const constraint: DeclareConstraint = { + id: "bad-response", + template: "response", + enabled: true, + activation: { + relation: "and", + predicates: [{}], + }, + target: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "" }, + captures: [ + { alias: "bad", source: "outputs", path: ["id"] }, + ], + }, + ], + }, + }; + + expect(validateDeclareConstraint(constraint)).toEqual([ + "activation.predicates[0] must define a transition name or data condition.", + "target.predicates[0].transition must not be empty.", + "target.predicates[0] must not define activation captures.", + ]); + }); +}); diff --git a/frontend/src/graph/declareConstraints.ts b/frontend/src/graph/declareConstraints.ts new file mode 100644 index 0000000..cf4ca8e --- /dev/null +++ b/frontend/src/graph/declareConstraints.ts @@ -0,0 +1,197 @@ +import type { TransitionCondition } from "./transitionConditions"; +import type { + CaptureDefinition, + CorrelationCondition, +} from "./transitionCorrelation"; + +export type DeclareTemplateId = + | "at-least" + | "at-most" + | "exactly" + | "exactly-consecutive" + | "init" + | "end" + | "choice" + | "exclusive-choice" + | "responded-existence" + | "not-responded-existence" + | "coexistence" + | "not-coexistence" + | "response" + | "not-response" + | "chain-response" + | "not-chain-response" + | "alternate-response" + | "not-alternate-response" + | "precedence" + | "not-precedence" + | "chain-precedence" + | "not-chain-precedence" + | "alternate-precedence" + | "not-alternate-precedence" + | "succession" + | "not-succession" + | "chain-succession" + | "not-chain-succession" + | "alternate-succession" + | "not-alternate-succession"; + +export type DeclareTemplateCategory = + | "cardinality" + | "position" + | "choice" + | "existence" + | "future" + | "past" + | "bidirectional"; + +export type DeclarePredicateRole = "activation" | "target" | "between"; +export type ActivityRelation = "and" | "or"; + +export type TransitionNameMatcher = { + operator: "equals"; + value: string; +}; + +export type DeclarePredicate = { + transition?: TransitionNameMatcher; + condition?: TransitionCondition; + captures?: CaptureDefinition[]; +}; + +export type DeclarePredicateGroup = { + relation: ActivityRelation; + predicates: DeclarePredicate[]; +}; + +export type DeclareConstraint = { + id: string; + template: DeclareTemplateId; + enabled: boolean; + activation?: DeclarePredicateGroup; + target?: DeclarePredicateGroup; + between?: DeclarePredicateGroup; + correlation?: CorrelationCondition; + count?: number; +}; + +export type DeclareTemplateDefinition = { + id: DeclareTemplateId; + displayName: string; + category: DeclareTemplateCategory; + requiredRoles: DeclarePredicateRole[]; + supportsCount: boolean; + supportsCorrelation: boolean; + description: string; +}; + +const DEFINITIONS: readonly DeclareTemplateDefinition[] = [ + { id: "at-least", displayName: "At least N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs at least N times." }, + { id: "at-most", displayName: "At most N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs at most N times." }, + { id: "exactly", displayName: "Exactly N", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs exactly N times." }, + { id: "exactly-consecutive", displayName: "Exactly N consecutively", category: "cardinality", requiredRoles: ["activation"], supportsCount: true, supportsCorrelation: false, description: "The activation occurs exactly N times consecutively." }, + { id: "init", displayName: "Init", category: "position", requiredRoles: ["activation"], supportsCount: false, supportsCorrelation: false, description: "The first transition matches the activation." }, + { id: "end", displayName: "End", category: "position", requiredRoles: ["activation"], supportsCount: false, supportsCorrelation: false, description: "The last transition matches the activation." }, + { id: "choice", displayName: "Choice", category: "choice", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: false, description: "The activation or target occurs." }, + { id: "exclusive-choice", displayName: "Exclusive choice", category: "choice", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: false, description: "Exactly one of activation and target occurs." }, + { id: "responded-existence", displayName: "Responded existence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "If the activation occurs, a correlated target occurs before or after it." }, + { id: "not-responded-existence", displayName: "Not responded existence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "If the activation occurs, no correlated target occurs in the path." }, + { id: "coexistence", displayName: "Coexistence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Activation and target either both occur or both do not occur." }, + { id: "not-coexistence", displayName: "Not coexistence", category: "existence", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Activation and target do not both occur." }, + { id: "response", displayName: "Response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every activation is eventually followed by a correlated target." }, + { id: "not-response", displayName: "Not response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No activation is followed later by a correlated target." }, + { id: "chain-response", displayName: "Chain response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every activation is immediately followed by a correlated target." }, + { id: "not-chain-response", displayName: "Not chain response", category: "future", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No activation is immediately followed by a correlated target." }, + { id: "alternate-response", displayName: "Alternate response", category: "future", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Every activation is followed by a correlated target without another qualifying activation or forbidden between event." }, + { id: "not-alternate-response", displayName: "Not alternate response", category: "future", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-response semantics." }, + { id: "precedence", displayName: "Precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every target has a correlated activation before it." }, + { id: "not-precedence", displayName: "Not precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No target has a correlated activation before it." }, + { id: "chain-precedence", displayName: "Chain precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Every target is immediately preceded by a correlated activation." }, + { id: "not-chain-precedence", displayName: "Not chain precedence", category: "past", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "No target is immediately preceded by a correlated activation." }, + { id: "alternate-precedence", displayName: "Alternate precedence", category: "past", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Every target has a correlated activation before it without another qualifying target or forbidden between event." }, + { id: "not-alternate-precedence", displayName: "Not alternate precedence", category: "past", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-precedence semantics." }, + { id: "succession", displayName: "Succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Response and precedence both hold." }, + { id: "not-succession", displayName: "Not succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Negative succession semantics." }, + { id: "chain-succession", displayName: "Chain succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Chain response and chain precedence both hold." }, + { id: "not-chain-succession", displayName: "Not chain succession", category: "bidirectional", requiredRoles: ["activation", "target"], supportsCount: false, supportsCorrelation: true, description: "Negative chain-succession semantics." }, + { id: "alternate-succession", displayName: "Alternate succession", category: "bidirectional", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Alternate response and alternate precedence both hold." }, + { id: "not-alternate-succession", displayName: "Not alternate succession", category: "bidirectional", requiredRoles: ["activation", "target", "between"], supportsCount: false, supportsCorrelation: true, description: "Specialized negative alternate-succession semantics." }, +] as const; + +export const DECLARE_TEMPLATE_DEFINITIONS: readonly DeclareTemplateDefinition[] = + DEFINITIONS; + +const DEFINITION_BY_ID = new Map( + DEFINITIONS.map((definition) => [definition.id, definition]), +); + +export function getDeclareTemplateDefinition( + id: DeclareTemplateId, +): DeclareTemplateDefinition { + const definition = DEFINITION_BY_ID.get(id); + if (!definition) { + throw new Error(`Unknown Declare template: ${id}.`); + } + return definition; +} + +function validatePredicateGroup( + group: DeclarePredicateGroup | undefined, + role: DeclarePredicateRole, +): string[] { + if (!group) { + return [`${role} is required.`]; + } + if (group.predicates.length === 0) { + return [`${role} must contain at least one predicate.`]; + } + const errors: string[] = []; + group.predicates.forEach((predicate, index) => { + if (!predicate.transition && !predicate.condition) { + errors.push( + `${role}.predicates[${index}] must define a transition name or data condition.`, + ); + } + if (predicate.transition?.value.trim() === "") { + errors.push(`${role}.predicates[${index}].transition must not be empty.`); + } + if ((predicate.captures?.length ?? 0) > 0 && role !== "activation") { + errors.push(`${role}.predicates[${index}] must not define activation captures.`); + } + }); + return errors; +} + +export function validateDeclareConstraint( + constraint: DeclareConstraint, +): string[] { + const errors: string[] = []; + if (constraint.id.trim() === "") { + errors.push("Constraint ID must not be empty."); + } + + const definition = getDeclareTemplateDefinition(constraint.template); + for (const role of definition.requiredRoles) { + errors.push(...validatePredicateGroup(constraint[role], role)); + } + + if (definition.supportsCount) { + if (!Number.isInteger(constraint.count) || (constraint.count ?? 0) < 0) { + errors.push("Count must be a non-negative integer."); + } + } else if (constraint.count !== undefined) { + errors.push(`${definition.displayName} does not support a count.`); + } + + if (constraint.correlation && !definition.supportsCorrelation) { + errors.push(`${definition.displayName} does not support correlation conditions.`); + } + + for (const role of ["activation", "target", "between"] as const) { + if (!definition.requiredRoles.includes(role) && constraint[role]) { + errors.push(`${definition.displayName} does not use ${role}.`); + } + } + + return errors; +} diff --git a/frontend/src/graph/declareExistenceMonitors.test.ts b/frontend/src/graph/declareExistenceMonitors.test.ts new file mode 100644 index 0000000..1f5e1b0 --- /dev/null +++ b/frontend/src/graph/declareExistenceMonitors.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createCoexistenceMonitor, + createNotCoexistenceMonitor, + createNotRespondedExistenceMonitor, + createRespondedExistenceMonitor, +} from "./declareExistenceMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; + +describe("Responded existence", () => { + it("is vacuously satisfied without an activation", () => { + const monitor = createRespondedExistenceMonitor(group("A"), group("B")); + expect(status(monitor, [X])).toEqual({ viable: true, accepting: true }); + }); + + it("allows the target before or after the activation", () => { + const monitor = createRespondedExistenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: true, + accepting: false, + }); + }); + + it("correlates targets before and after each activation", () => { + const monitor = createRespondedExistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [B(20), A(10)])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not responded existence", () => { + it("forbids a matching pair in either order", () => { + const monitor = createNotRespondedExistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [B(10), A(10)])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: true }); + }); +}); + +describe("Coexistence", () => { + it("requires both sides or neither side", () => { + const monitor = createCoexistenceMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [X])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [{ transition: "A" }])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + }); + + it("requires correlated counterparts for every activation and target", () => { + const monitor = createCoexistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), A(20), B(10)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), B(10), B(20)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not coexistence", () => { + it("accepts either side alone and forbids a correlated pair", () => { + const monitor = createNotCoexistenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: false, accepting: false }); + }); +}); diff --git a/frontend/src/graph/declareExistenceMonitors.ts b/frontend/src/graph/declareExistenceMonitors.ts new file mode 100644 index 0000000..a05f6b6 --- /dev/null +++ b/frontend/src/graph/declareExistenceMonitors.ts @@ -0,0 +1,253 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +type SeenActivation = { + bindings: ActivationBindings; +}; + +export type RespondedExistenceState = { + activations: SeenActivation[]; + pendingActivations: SeenActivation[]; + seenTargets: DeclareTransition[]; + violated: boolean; +}; + +export type CoexistenceState = RespondedExistenceState & { + unmatchedTargets: DeclareTransition[]; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): SeenActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function correlates( + correlation: CorrelationCondition | undefined, + activation: SeenActivation, + targetEdge: DeclareTransition, +): boolean { + return correlation + ? evaluateCorrelationCondition( + correlation, + targetEdge, + activation.bindings, + ).matches + : true; +} + +function anyCorrelatedTarget( + targets: readonly DeclareTransition[], + activation: SeenActivation, + correlation: CorrelationCondition | undefined, +): boolean { + return targets.some((target) => correlates(correlation, activation, target)); +} + +function anyCorrelatedActivation( + activations: readonly SeenActivation[], + target: DeclareTransition, + correlation: CorrelationCondition | undefined, +): boolean { + return activations.some((activation) => + correlates(correlation, activation, target), + ); +} + +export function createRespondedExistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const seenTargets = targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets; + const existingPending = targetOccurs + ? state.pendingActivations.filter( + (candidate) => !correlates(correlation, candidate, edge), + ) + : state.pendingActivations; + const newActivations = activationMatches(activation, edge); + const newPending = newActivations.filter( + (candidate) => + !anyCorrelatedTarget(seenTargets, candidate, correlation), + ); + + return { + activations: [...state.activations, ...newActivations], + pendingActivations: [...existingPending, ...newPending], + seenTargets, + violated: false, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && state.pendingActivations.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotRespondedExistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const newActivations = activationMatches(activation, edge); + const targetViolates = + targetOccurs && + anyCorrelatedActivation(state.activations, edge, correlation); + const activationViolates = newActivations.some((candidate) => + anyCorrelatedTarget(state.seenTargets, candidate, correlation), + ); + + return { + activations: [...state.activations, ...newActivations], + pendingActivations: [], + seenTargets: targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets, + violated: targetViolates || activationViolates, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createCoexistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + activations: [], + pendingActivations: [], + seenTargets: [], + unmatchedTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const newActivations = activationMatches(activation, edge); + const allActivations = [...state.activations, ...newActivations]; + const allTargets = targetOccurs + ? [...state.seenTargets, edge] + : state.seenTargets; + + const pendingActivations = [ + ...state.pendingActivations.filter( + (candidate) => + !targetOccurs || !correlates(correlation, candidate, edge), + ), + ...newActivations.filter( + (candidate) => + !anyCorrelatedTarget(allTargets, candidate, correlation), + ), + ]; + + const unmatchedTargets = [ + ...state.unmatchedTargets.filter( + (candidate) => + !newActivations.some((newActivation) => + correlates(correlation, newActivation, candidate), + ), + ), + ...(targetOccurs && + !anyCorrelatedActivation(allActivations, edge, correlation) + ? [edge] + : []), + ]; + + return { + activations: allActivations, + pendingActivations, + seenTargets: allTargets, + unmatchedTargets, + violated: false, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: + !state.violated && + state.pendingActivations.length === 0 && + state.unmatchedTargets.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotCoexistenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return createNotRespondedExistenceMonitor( + activation, + target, + correlation, + ); +} diff --git a/frontend/src/graph/declareMonitor.test.ts b/frontend/src/graph/declareMonitor.test.ts new file mode 100644 index 0000000..5dbecd6 --- /dev/null +++ b/frontend/src/graph/declareMonitor.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + advanceMonitorSet, + canonicalMonitorStateKey, + createMonitorSet, + getMonitorSetStatus, + monitorSetStateKey, + type DeclareMonitor, +} from "./declareMonitor"; + +type TestState = { + count: number; + violated: boolean; +}; + +function atLeastMonitor(minimum: number): DeclareMonitor { + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + ...state, + count: state.count + (edge.transition === "A" ? 1 : 0), + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.count >= minimum, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +function forbidMonitor(name: string): DeclareMonitor { + return { + initialState: () => ({ count: 0, violated: false }), + advance: (state, edge) => ({ + count: state.count, + violated: state.violated || edge.transition === name, + }), + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +describe("canonicalMonitorStateKey", () => { + it("is stable across object property insertion order", () => { + expect(canonicalMonitorStateKey({ b: 2, a: { d: 4, c: 3 } })).toBe( + canonicalMonitorStateKey({ a: { c: 3, d: 4 }, b: 2 }), + ); + }); +}); + +describe("monitor sets", () => { + it("distinguishes viable pending states from accepting states", () => { + const initial = createMonitorSet([ + { id: "at-least-two-a", monitor: atLeastMonitor(2) }, + ]); + + expect(getMonitorSetStatus(initial)).toEqual({ + viable: true, + accepting: false, + rejectedConstraintIds: [], + pendingConstraintIds: ["at-least-two-a"], + }); + + const once = advanceMonitorSet(initial, { transition: "A" }); + const twice = advanceMonitorSet(once, { transition: "A" }); + expect(getMonitorSetStatus(twice).accepting).toBe(true); + }); + + it("reports irreversible violations separately", () => { + const initial = createMonitorSet([ + { id: "needs-a", monitor: atLeastMonitor(1) }, + { id: "forbid-x", monitor: forbidMonitor("X") }, + ]); + const next = advanceMonitorSet(initial, { transition: "X" }); + + expect(getMonitorSetStatus(next)).toEqual({ + viable: false, + accepting: false, + rejectedConstraintIds: ["forbid-x"], + pendingConstraintIds: ["needs-a"], + }); + }); + + it("does not mutate earlier monitor states", () => { + const initial = createMonitorSet([ + { id: "needs-a", monitor: atLeastMonitor(1) }, + ]); + const next = advanceMonitorSet(initial, { transition: "A" }); + + expect(getMonitorSetStatus(initial).accepting).toBe(false); + expect(getMonitorSetStatus(next).accepting).toBe(true); + }); + + it("creates a deterministic combined state key", () => { + const entries = createMonitorSet([ + { id: "first", monitor: atLeastMonitor(1) }, + { id: "second", monitor: forbidMonitor("X") }, + ]); + + expect(monitorSetStateKey(entries)).toBe(monitorSetStateKey(entries)); + }); +}); diff --git a/frontend/src/graph/declareMonitor.ts b/frontend/src/graph/declareMonitor.ts new file mode 100644 index 0000000..6f5dea1 --- /dev/null +++ b/frontend/src/graph/declareMonitor.ts @@ -0,0 +1,96 @@ +import type { DeclareTransition } from "./declarePredicates"; + +export type MonitorStatus = { + viable: boolean; + accepting: boolean; +}; + +export interface DeclareMonitor { + initialState(): State; + advance(state: State, edge: DeclareTransition): State; + status(state: State): MonitorStatus; + stateKey(state: State): string; +} + +export type MonitorSetEntry = { + id: string; + monitor: DeclareMonitor; + state: State; +}; + +export type MonitorSetStatus = MonitorStatus & { + rejectedConstraintIds: string[]; + pendingConstraintIds: string[]; +}; + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalize(child)]), + ); + } + + return value; +} + +export function canonicalMonitorStateKey(state: unknown): string { + return JSON.stringify(canonicalize(state)); +} + +export function createMonitorSet( + monitors: readonly { id: string; monitor: DeclareMonitor }[], +): MonitorSetEntry[] { + return monitors.map(({ id, monitor }) => ({ + id, + monitor, + state: monitor.initialState(), + })); +} + +export function advanceMonitorSet( + entries: readonly MonitorSetEntry[], + edge: DeclareTransition, +): MonitorSetEntry[] { + return entries.map((entry) => ({ + ...entry, + state: entry.monitor.advance(entry.state, edge), + })); +} + +export function getMonitorSetStatus( + entries: readonly MonitorSetEntry[], +): MonitorSetStatus { + const rejectedConstraintIds: string[] = []; + const pendingConstraintIds: string[] = []; + + entries.forEach((entry) => { + const status = entry.monitor.status(entry.state); + if (!status.viable) { + rejectedConstraintIds.push(entry.id); + } else if (!status.accepting) { + pendingConstraintIds.push(entry.id); + } + }); + + return { + viable: rejectedConstraintIds.length === 0, + accepting: + rejectedConstraintIds.length === 0 && pendingConstraintIds.length === 0, + rejectedConstraintIds, + pendingConstraintIds, + }; +} + +export function monitorSetStateKey( + entries: readonly MonitorSetEntry[], +): string { + return JSON.stringify( + entries.map((entry) => [entry.id, entry.monitor.stateKey(entry.state)]), + ); +} diff --git a/frontend/src/graph/declareMonitorFactory.test.ts b/frontend/src/graph/declareMonitorFactory.test.ts new file mode 100644 index 0000000..6008a94 --- /dev/null +++ b/frontend/src/graph/declareMonitorFactory.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from "vitest"; + +import { + DECLARE_TEMPLATE_DEFINITIONS, + type DeclareConstraint, + type DeclarePredicateGroup, + type DeclareTemplateId, +} from "./declareConstraints"; +import { + compileDeclareConstraints, + createDeclareMonitor, + validateExecutableDeclareConstraint, +} from "./declareMonitorFactory"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function constraintFor(template: DeclareTemplateId): DeclareConstraint { + const definition = DECLARE_TEMPLATE_DEFINITIONS.find( + (candidate) => candidate.id === template, + ); + if (!definition) { + throw new Error(`Missing definition for ${template}.`); + } + + return { + id: `constraint-${template}`, + template, + enabled: true, + activation: group("A"), + target: definition.requiredRoles.includes("target") + ? group("B") + : undefined, + between: definition.requiredRoles.includes("between") + ? group("C") + : undefined, + count: definition.supportsCount ? 1 : undefined, + }; +} + +describe("createDeclareMonitor", () => { + it("creates an executable monitor for every registered template", () => { + for (const definition of DECLARE_TEMPLATE_DEFINITIONS) { + const monitor = createDeclareMonitor(constraintFor(definition.id)); + expect(monitor.initialState()).toBeDefined(); + expect(typeof monitor.advance).toBe("function"); + expect(typeof monitor.status).toBe("function"); + expect(typeof monitor.stateKey).toBe("function"); + } + }); + + it("creates a working cardinality monitor", () => { + const monitor = createDeclareMonitor({ + id: "at-least-one-a", + template: "at-least", + enabled: true, + activation: group("A"), + count: 1, + }); + const initial = monitor.initialState(); + const next = monitor.advance(initial, { transition: "A" }); + + expect(monitor.status(initial).accepting).toBe(false); + expect(monitor.status(next).accepting).toBe(true); + }); + + it("creates a working correlated response monitor", () => { + const monitor = createDeclareMonitor({ + id: "same-request-response", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], + }, + target: group("B"), + correlation: { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }); + + const activated = monitor.advance(monitor.initialState(), { + transition: "A", + inputs: { id: 42 }, + }); + const wrongTarget = monitor.advance(activated, { + transition: "B", + outputs: { id: 57 }, + }); + const rightTarget = monitor.advance(wrongTarget, { + transition: "B", + outputs: { id: 42 }, + }); + + expect(monitor.status(activated).accepting).toBe(false); + expect(monitor.status(wrongTarget).accepting).toBe(false); + expect(monitor.status(rightTarget).accepting).toBe(true); + }); + + it("rejects unknown correlation aliases before execution", () => { + const constraint: DeclareConstraint = { + id: "bad-correlation", + template: "response", + enabled: true, + activation: group("A"), + target: group("B"), + correlation: { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "missing" }, + }, + }; + + expect(validateExecutableDeclareConstraint(constraint)).toContain( + "correlation.right references unknown activation variable $missing.", + ); + expect(() => createDeclareMonitor(constraint)).toThrow( + "Declare constraint bad-correlation is invalid", + ); + }); + + it("validates duplicate aliases across activation predicates", () => { + const constraint: DeclareConstraint = { + id: "duplicate-alias", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A1" }, + captures: [{ alias: "id", source: "inputs", path: ["id"] }], + }, + { + transition: { operator: "equals", value: "A2" }, + captures: [{ alias: "id", source: "inputs", path: ["id"] }], + }, + ], + }, + target: group("B"), + }; + + expect(validateExecutableDeclareConstraint(constraint)).toContain( + "Duplicate capture alias: $id.", + ); + }); +}); + +describe("compileDeclareConstraints", () => { + it("compiles only enabled constraints and preserves order", () => { + const result = compileDeclareConstraints([ + { + id: "first", + template: "at-least", + enabled: true, + activation: group("A"), + count: 1, + }, + { + id: "disabled", + template: "at-most", + enabled: false, + activation: group("B"), + count: 1, + }, + { + id: "second", + template: "init", + enabled: true, + activation: group("A"), + }, + ]); + + expect(result.map((entry) => entry.id)).toEqual(["first", "second"]); + }); + + it("rejects duplicate enabled IDs", () => { + expect(() => + compileDeclareConstraints([ + { + id: "duplicate", + template: "init", + enabled: true, + activation: group("A"), + }, + { + id: "duplicate", + template: "end", + enabled: true, + activation: group("B"), + }, + ]), + ).toThrow("Duplicate enabled Declare constraint ID: duplicate."); + }); + + it("allows a disabled constraint to share an enabled ID", () => { + expect( + compileDeclareConstraints([ + { + id: "same", + template: "init", + enabled: true, + activation: group("A"), + }, + { + id: "same", + template: "end", + enabled: false, + activation: group("B"), + }, + ]), + ).toHaveLength(1); + }); +}); diff --git a/frontend/src/graph/declareMonitorFactory.ts b/frontend/src/graph/declareMonitorFactory.ts new file mode 100644 index 0000000..f218b75 --- /dev/null +++ b/frontend/src/graph/declareMonitorFactory.ts @@ -0,0 +1,315 @@ +import { + createAtLeastMonitor, + createAtMostMonitor, + createChoiceMonitor, + createEndMonitor, + createExactlyConsecutiveMonitor, + createExactlyMonitor, + createExclusiveChoiceMonitor, + createInitMonitor, +} from "./declareBasicMonitors"; +import { + validateDeclareConstraint, + type DeclareConstraint, + type DeclarePredicateGroup, +} from "./declareConstraints"; +import { + createCoexistenceMonitor, + createNotCoexistenceMonitor, + createNotRespondedExistenceMonitor, + createRespondedExistenceMonitor, +} from "./declareExistenceMonitors"; +import type { DeclareMonitor } from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; +import { + createAlternateSuccessionMonitor, + createChainSuccessionMonitor, + createNotAlternateSuccessionMonitor, + createNotChainSuccessionMonitor, + createNotSuccessionMonitor, + createSuccessionMonitor, +} from "./declareSuccessionMonitors"; +import { + validateCaptureDefinitions, + validateCorrelationCondition, +} from "./transitionCorrelation"; + +export type CompiledDeclareConstraint = { + id: string; + monitor: DeclareMonitor; + requiresExercise: boolean; + isExercisedBy(edge: DeclareTransition): boolean; +}; + +function requireGroup( + constraint: DeclareConstraint, + role: "activation" | "target" | "between", +): DeclarePredicateGroup { + const group = constraint[role]; + if (!group) { + throw new Error(`${role} is required.`); + } + return group; +} + +function collectActivationAliases(constraint: DeclareConstraint): string[] { + return ( + constraint.activation?.predicates.flatMap((predicate) => + (predicate.captures ?? []).map((capture) => capture.alias), + ) ?? [] + ); +} + +export function validateExecutableDeclareConstraint( + constraint: DeclareConstraint, +): string[] { + const errors = [...validateDeclareConstraint(constraint)]; + const captures = + constraint.activation?.predicates.flatMap( + (predicate) => predicate.captures ?? [], + ) ?? []; + errors.push(...validateCaptureDefinitions(captures)); + + if (constraint.correlation) { + errors.push( + ...validateCorrelationCondition( + constraint.correlation, + collectActivationAliases(constraint), + ), + ); + } + + return [...new Set(errors)]; +} + +export function createDeclareMonitor( + constraint: DeclareConstraint, +): DeclareMonitor { + const errors = validateExecutableDeclareConstraint(constraint); + if (errors.length > 0) { + throw new Error( + `Declare constraint ${constraint.id || ""} is invalid:\n${errors + .map((error) => `- ${error}`) + .join("\n")}`, + ); + } + + const activation = requireGroup(constraint, "activation"); + const target = () => requireGroup(constraint, "target"); + const between = () => requireGroup(constraint, "between"); + const count = () => constraint.count ?? 0; + const correlation = constraint.correlation; + + switch (constraint.template) { + case "at-least": + return createAtLeastMonitor(activation, count()); + case "at-most": + return createAtMostMonitor(activation, count()); + case "exactly": + return createExactlyMonitor(activation, count()); + case "exactly-consecutive": + return createExactlyConsecutiveMonitor(activation, count()); + case "init": + return createInitMonitor(activation); + case "end": + return createEndMonitor(activation); + case "choice": + return createChoiceMonitor(activation, target()); + case "exclusive-choice": + return createExclusiveChoiceMonitor(activation, target()); + case "responded-existence": + return createRespondedExistenceMonitor( + activation, + target(), + correlation, + ); + case "not-responded-existence": + return createNotRespondedExistenceMonitor( + activation, + target(), + correlation, + ); + case "coexistence": + return createCoexistenceMonitor(activation, target(), correlation); + case "not-coexistence": + return createNotCoexistenceMonitor(activation, target(), correlation); + case "response": + return createResponseMonitor(activation, target(), correlation); + case "not-response": + return createNotResponseMonitor(activation, target(), correlation); + case "chain-response": + return createChainResponseMonitor(activation, target(), correlation); + case "not-chain-response": + return createNotChainResponseMonitor( + activation, + target(), + correlation, + ); + case "alternate-response": + return createAlternateResponseMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-response": + return createNotAlternateResponseMonitor( + activation, + target(), + between(), + correlation, + ); + case "precedence": + return createPrecedenceMonitor(activation, target(), correlation); + case "not-precedence": + return createNotPrecedenceMonitor(activation, target(), correlation); + case "chain-precedence": + return createChainPrecedenceMonitor(activation, target(), correlation); + case "not-chain-precedence": + return createNotChainPrecedenceMonitor( + activation, + target(), + correlation, + ); + case "alternate-precedence": + return createAlternatePrecedenceMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-precedence": + return createNotAlternatePrecedenceMonitor( + activation, + target(), + between(), + correlation, + ); + case "succession": + return createSuccessionMonitor(activation, target(), correlation); + case "not-succession": + return createNotSuccessionMonitor(activation, target(), correlation); + case "chain-succession": + return createChainSuccessionMonitor(activation, target(), correlation); + case "not-chain-succession": + return createNotChainSuccessionMonitor( + activation, + target(), + correlation, + ); + case "alternate-succession": + return createAlternateSuccessionMonitor( + activation, + target(), + between(), + correlation, + ); + case "not-alternate-succession": + return createNotAlternateSuccessionMonitor( + activation, + target(), + between(), + correlation, + ); + } +} + + +function groupMatches( + group: DeclarePredicateGroup | undefined, + edge: DeclareTransition, +): boolean { + return group !== undefined && evaluateDeclarePredicateGroup(group, edge).matches; +} + +function createExercisePolicy(constraint: DeclareConstraint): Pick< + CompiledDeclareConstraint, + "requiresExercise" | "isExercisedBy" +> { + const activationMatches = (edge: DeclareTransition) => + groupMatches(constraint.activation, edge); + const targetMatches = (edge: DeclareTransition) => + groupMatches(constraint.target, edge); + const eitherMatches = (edge: DeclareTransition) => + activationMatches(edge) || targetMatches(edge); + + switch (constraint.template) { + case "at-most": + return { requiresExercise: false, isExercisedBy: activationMatches }; + case "at-least": + case "exactly": + case "exactly-consecutive": + return { + requiresExercise: (constraint.count ?? 0) > 0, + isExercisedBy: activationMatches, + }; + case "init": + case "end": + return { requiresExercise: true, isExercisedBy: activationMatches }; + case "precedence": + case "not-precedence": + case "chain-precedence": + case "not-chain-precedence": + case "alternate-precedence": + case "not-alternate-precedence": + return { requiresExercise: true, isExercisedBy: targetMatches }; + case "response": + case "not-response": + case "chain-response": + case "not-chain-response": + case "alternate-response": + case "not-alternate-response": + case "responded-existence": + case "not-responded-existence": + return { requiresExercise: true, isExercisedBy: activationMatches }; + case "choice": + case "exclusive-choice": + case "coexistence": + case "not-coexistence": + case "succession": + case "not-succession": + case "chain-succession": + case "not-chain-succession": + case "alternate-succession": + case "not-alternate-succession": + return { requiresExercise: true, isExercisedBy: eitherMatches }; + } +} +export function compileDeclareConstraints( + constraints: readonly DeclareConstraint[], +): CompiledDeclareConstraint[] { + const enabled = constraints.filter((constraint) => constraint.enabled); + const seenIds = new Set(); + + enabled.forEach((constraint) => { + if (seenIds.has(constraint.id)) { + throw new Error(`Duplicate enabled Declare constraint ID: ${constraint.id}.`); + } + seenIds.add(constraint.id); + }); + + return enabled.map((constraint) => ({ + id: constraint.id, + monitor: createDeclareMonitor(constraint), + ...createExercisePolicy(constraint), + })); +} diff --git a/frontend/src/graph/declarePrecedenceMonitors.test.ts b/frontend/src/graph/declarePrecedenceMonitors.test.ts new file mode 100644 index 0000000..a061547 --- /dev/null +++ b/frontend/src/graph/declarePrecedenceMonitors.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Precedence", () => { + it("is vacuously satisfied when no target occurs", () => { + expect( + status(createPrecedenceMonitor(group("A"), group("B")), [X]), + ).toEqual({ viable: true, accepting: true }); + }); + + it("requires an activation strictly before every target", () => { + const monitor = createPrecedenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("does not allow one edge to precede itself", () => { + const monitor = createPrecedenceMonitor(group("A"), group("A")); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses a correlated earlier activation", () => { + const monitor = createPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [A(10), A(20), B(20)]).accepting).toBe(true); + }); +}); + +describe("Not precedence", () => { + it("forbids a correlated activation before a target", () => { + const monitor = createNotPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain precedence", () => { + it("requires the immediately previous edge to be a correlated activation", () => { + const monitor = createChainPrecedenceMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [A(10), X, B(10)])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements not chain precedence", () => { + const monitor = createNotChainPrecedenceMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate precedence", () => { + it("requires an activation since the previous target", () => { + const monitor = createAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("rejects the configured between predicate after the activation", () => { + const monitor = createAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses correlation for the preceding activation", () => { + const monitor = createAlternatePrecedenceMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Specialized negative alternate precedence", () => { + it("forbids A then B when only the allowed C predicate occurs between", () => { + const monitor = createNotAlternatePrecedenceMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("applies correlation to the forbidden preceding activation", () => { + const monitor = createNotAlternatePrecedenceMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), C, B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), C, B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); diff --git a/frontend/src/graph/declarePrecedenceMonitors.ts b/frontend/src/graph/declarePrecedenceMonitors.ts new file mode 100644 index 0000000..cabc833 --- /dev/null +++ b/frontend/src/graph/declarePrecedenceMonitors.ts @@ -0,0 +1,323 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +export type SeenActivation = { + bindings: ActivationBindings; +}; + +export type PrecedenceMonitorState = { + seenActivations: SeenActivation[]; + violated: boolean; +}; + +export type ChainPrecedenceMonitorState = { + previousActivations: SeenActivation[]; + violated: boolean; +}; + +export type AlternatePrecedenceMonitorState = { + candidateActivations: SeenActivation[]; + blocked: boolean; + violated: boolean; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): SeenActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function correlates( + correlation: CorrelationCondition | undefined, + activation: SeenActivation, + targetEdge: DeclareTransition, +): boolean { + return correlation + ? evaluateCorrelationCondition( + correlation, + targetEdge, + activation.bindings, + ).matches + : true; +} + +function hasCorrelatedActivation( + activations: readonly SeenActivation[], + correlation: CorrelationCondition | undefined, + targetEdge: DeclareTransition, +): boolean { + return activations.some((activation) => + correlates(correlation, activation, targetEdge), + ); +} + +export function createPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ seenActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + !hasCorrelatedActivation(state.seenActivations, correlation, edge); + + return { + seenActivations: [ + ...state.seenActivations, + ...activationMatches(activation, edge), + ], + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ seenActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + hasCorrelatedActivation(state.seenActivations, correlation, edge); + + return { + seenActivations: [ + ...state.seenActivations, + ...activationMatches(activation, edge), + ], + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChainPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ previousActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + !hasCorrelatedActivation(state.previousActivations, correlation, edge); + + return { + previousActivations: activationMatches(activation, edge), + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotChainPrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ previousActivations: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + const violated = + targetOccurs && + hasCorrelatedActivation(state.previousActivations, correlation, edge); + + return { + previousActivations: activationMatches(activation, edge), + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAlternatePrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + candidateActivations: [], + blocked: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + if (targetOccurs) { + const fulfilled = + !state.blocked && + hasCorrelatedActivation( + state.candidateActivations, + correlation, + edge, + ); + return { + candidateActivations: activationMatches(activation, edge), + blocked: false, + violated: !fulfilled, + }; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + return { + candidateActivations: newActivations, + blocked: false, + violated: false, + }; + } + + if ( + state.candidateActivations.length > 0 && + groupMatches(between, edge) + ) { + return { ...state, blocked: true }; + } + + return state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotAlternatePrecedenceMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + candidateActivations: [], + blocked: false, + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const targetOccurs = groupMatches(target, edge); + if (targetOccurs) { + const forbiddenSequence = + !state.blocked && + hasCorrelatedActivation( + state.candidateActivations, + correlation, + edge, + ); + return { + candidateActivations: activationMatches(activation, edge), + blocked: false, + violated: forbiddenSequence, + }; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + return { + candidateActivations: newActivations, + blocked: false, + violated: false, + }; + } + + if ( + state.candidateActivations.length > 0 && + !groupMatches(allowedBetween, edge) + ) { + return { ...state, blocked: true }; + } + + return state; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} diff --git a/frontend/src/graph/declarePredicates.test.ts b/frontend/src/graph/declarePredicates.test.ts new file mode 100644 index 0000000..ee8197e --- /dev/null +++ b/frontend/src/graph/declarePredicates.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateDeclarePredicate, + evaluateDeclarePredicateGroup, + expandPredicateGroup, +} from "./declarePredicates"; + +const edge = { + transition: "SubmitRequest", + inputs: { + request: { id: 42, priority: 7 }, + }, + outputs: {}, +}; + +describe("evaluateDeclarePredicate", () => { + it("matches transition identity and data together", () => { + const result = evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "priority"], + operator: ">=", + value: 5, + }, + }, + }, + edge, + ); + + expect(result).toEqual({ matches: true, bindings: {}, errors: [] }); + }); + + it("does not match when either identity or data fails", () => { + expect( + evaluateDeclarePredicate( + { transition: { operator: "equals", value: "Other" } }, + edge, + ).matches, + ).toBe(false); + + expect( + evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "priority"], + operator: ">", + value: 10, + }, + }, + }, + edge, + ).matches, + ).toBe(false); + }); + + it("captures activation bindings only after a successful match", () => { + expect( + evaluateDeclarePredicate( + { + transition: { operator: "equals", value: "SubmitRequest" }, + captures: [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + ], + }, + edge, + ), + ).toEqual({ + matches: true, + bindings: { request_id: 42 }, + errors: [], + }); + }); +}); + +describe("evaluateDeclarePredicateGroup", () => { + it("matches an OR group when any predicate matches", () => { + const result = evaluateDeclarePredicateGroup( + { + relation: "or", + predicates: [ + { transition: { operator: "equals", value: "Other" } }, + { transition: { operator: "equals", value: "SubmitRequest" } }, + ], + }, + edge, + ); + + expect(result.matches).toBe(true); + expect(result.predicateMatches.map((match) => match.predicateIndex)).toEqual([ + 1, + ]); + }); + + it("matches an AND group only when every predicate matches", () => { + const result = evaluateDeclarePredicateGroup( + { + relation: "and", + predicates: [ + { transition: { operator: "equals", value: "SubmitRequest" } }, + { + condition: { + type: "source", + source: "inputs", + condition: { + type: "comparison", + path: ["request", "id"], + operator: "=", + value: 42, + }, + }, + }, + ], + }, + edge, + ); + + expect(result.matches).toBe(true); + expect(result.predicateMatches).toHaveLength(2); + }); + + it("expands AND groups into independent primitive obligations", () => { + const group = { + relation: "and" as const, + predicates: [ + { transition: { operator: "equals" as const, value: "A" } }, + { transition: { operator: "equals" as const, value: "B" } }, + ], + }; + + expect(expandPredicateGroup(group)).toEqual([ + { relation: "or", predicates: [group.predicates[0]] }, + { relation: "or", predicates: [group.predicates[1]] }, + ]); + }); +}); diff --git a/frontend/src/graph/declarePredicates.ts b/frontend/src/graph/declarePredicates.ts new file mode 100644 index 0000000..93fb52f --- /dev/null +++ b/frontend/src/graph/declarePredicates.ts @@ -0,0 +1,98 @@ +import { + evaluateTransitionCondition, + type TransitionData, +} from "./transitionConditions"; +import { + captureActivationValues, + type ActivationBindings, +} from "./transitionCorrelation"; +import type { + DeclarePredicate, + DeclarePredicateGroup, +} from "./declareConstraints"; + +export type DeclareTransition = TransitionData & { + transition?: string; +}; + +export type PredicateMatch = { + predicateIndex: number; + bindings: ActivationBindings; +}; + +export type PredicateGroupEvaluation = { + matches: boolean; + predicateMatches: PredicateMatch[]; + errors: string[]; +}; + +export function evaluateDeclarePredicate( + predicate: DeclarePredicate, + edge: DeclareTransition, +): { matches: boolean; bindings: ActivationBindings; errors: string[] } { + if ( + predicate.transition && + edge.transition !== predicate.transition.value + ) { + return { matches: false, bindings: {}, errors: [] }; + } + + if (predicate.condition) { + const evaluation = evaluateTransitionCondition(predicate.condition, edge); + if (evaluation.errors.length > 0 || !evaluation.matches) { + return { + matches: false, + bindings: {}, + errors: evaluation.errors, + }; + } + } + + const capture = captureActivationValues(predicate.captures ?? [], edge); + if (capture.errors.length > 0) { + return { matches: false, bindings: {}, errors: capture.errors }; + } + + return { matches: true, bindings: capture.bindings, errors: [] }; +} + +export function evaluateDeclarePredicateGroup( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): PredicateGroupEvaluation { + const predicateMatches: PredicateMatch[] = []; + const errors: string[] = []; + + group.predicates.forEach((predicate, predicateIndex) => { + const evaluation = evaluateDeclarePredicate(predicate, edge); + errors.push(...evaluation.errors); + if (evaluation.matches) { + predicateMatches.push({ + predicateIndex, + bindings: evaluation.bindings, + }); + } + }); + + return { + matches: + group.relation === "or" + ? predicateMatches.length > 0 + : predicateMatches.length === group.predicates.length, + predicateMatches, + errors, + }; +} + +export function expandPredicateGroup( + group: DeclarePredicateGroup, +): DeclarePredicateGroup[] { + if (group.relation === "or") { + return [group]; + } + + return group.predicates.map((predicate) => ({ + relation: "or", + predicates: [predicate], + })); +} diff --git a/frontend/src/graph/declareResponseMonitors.test.ts b/frontend/src/graph/declareResponseMonitors.test.ts new file mode 100644 index 0000000..fbd8e08 --- /dev/null +++ b/frontend/src/graph/declareResponseMonitors.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { kind: "target" as const, source: "outputs" as const, path: ["id"] }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Response", () => { + it("is vacuously satisfied without activations", () => { + expect(status(createResponseMonitor(group("A"), group("B")), [X])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("keeps unmatched activations pending until a target occurs", () => { + const monitor = createResponseMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + }); + + it("correlates one target with each pending activation independently", () => { + const monitor = createResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + const afterOneTarget = run(monitor, [A(10), A(20), B(10)]); + expect(afterOneTarget.pending).toEqual([{ bindings: { request_id: 20 } }]); + expect(monitor.status(afterOneTarget).accepting).toBe(false); + expect(status(monitor, [A(10), A(20), B(10), B(20)]).accepting).toBe(true); + }); + + it("processes a target before adding a same-edge activation", () => { + const both = group("A"); + const monitor = createResponseMonitor(both, both); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(false); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }]).accepting).toBe(false); + }); +}); + +describe("Not response", () => { + it("rejects a later correlated target but permits earlier targets", () => { + const monitor = createNotResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [B(10), A(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(10)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain response", () => { + it("requires the next edge to be a correlated target", () => { + const monitor = createChainResponseMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10)])).toEqual({ viable: true, accepting: false }); + expect(status(monitor, [A(10), B(10)])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [A(10), X])).toEqual({ viable: false, accepting: false }); + expect(status(monitor, [A(10), B(20)])).toEqual({ viable: false, accepting: false }); + }); + + it("implements not chain response", () => { + const monitor = createNotChainResponseMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, X]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate response", () => { + it("requires a target before another activation", () => { + const monitor = createAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("rejects the configured between predicate while waiting", () => { + const monitor = createAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("uses target correlation before fulfilling the activation", () => { + const monitor = createAlternateResponseMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(20)]).accepting).toBe(false); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + }); +}); + +describe("Specialized negative alternate response", () => { + it("rejects A followed by B with only the allowed C predicate between", () => { + const monitor = createNotAlternateResponseMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }]).accepting).toBe(false); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("applies correlation to the forbidden target", () => { + const monitor = createNotAlternateResponseMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), C, B(20)]).accepting).toBe(true); + expect(status(monitor, [A(10), C, B(10)]).accepting).toBe(false); + }); +}); diff --git a/frontend/src/graph/declareResponseMonitors.ts b/frontend/src/graph/declareResponseMonitors.ts new file mode 100644 index 0000000..8d194bd --- /dev/null +++ b/frontend/src/graph/declareResponseMonitors.ts @@ -0,0 +1,318 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import { + evaluateDeclarePredicateGroup, + type DeclareTransition, +} from "./declarePredicates"; +import { + evaluateCorrelationCondition, + type ActivationBindings, + type CorrelationCondition, +} from "./transitionCorrelation"; + +export type PendingActivation = { + bindings: ActivationBindings; +}; + +export type ResponseMonitorState = { + pending: PendingActivation[]; + violated: boolean; +}; + +export type ChainResponseMonitorState = { + pending: PendingActivation | null; + violated: boolean; +}; + +export type AlternateResponseMonitorState = { + pending: PendingActivation | null; + possibleTargets: DeclareTransition[]; + violated: boolean; +}; + +function groupMatches( + group: DeclarePredicateGroup, + edge: DeclareTransition, +): boolean { + return evaluateDeclarePredicateGroup(group, edge).matches; +} + +function activationMatches( + activation: DeclarePredicateGroup, + edge: DeclareTransition, +): PendingActivation[] { + const evaluation = evaluateDeclarePredicateGroup(activation, edge); + if (!evaluation.matches) { + return []; + } + + return evaluation.predicateMatches.map((match) => ({ + bindings: match.bindings, + })); +} + +function targetCorrelates( + target: DeclarePredicateGroup, + correlation: CorrelationCondition | undefined, + activation: PendingActivation, + edge: DeclareTransition, +): boolean { + if (!groupMatches(target, edge)) { + return false; + } + + return correlation + ? evaluateCorrelationCondition(correlation, edge, activation.bindings).matches + : true; +} + +function fulfillPending( + pending: readonly PendingActivation[], + target: DeclarePredicateGroup, + correlation: CorrelationCondition | undefined, + edge: DeclareTransition, +): PendingActivation[] { + return pending.filter( + (activation) => !targetCorrelates(target, correlation, activation, edge), + ); +} + +export function createResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: [], violated: false }), + advance: (state, edge) => { + const stillPending = fulfillPending( + state.pending, + target, + correlation, + edge, + ); + return { + pending: [...stillPending, ...activationMatches(activation, edge)], + violated: state.violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.pending.length === 0, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: [], violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const forbiddenTarget = state.pending.some((pending) => + targetCorrelates(target, correlation, pending, edge), + ); + return { + pending: [...state.pending, ...activationMatches(activation, edge)], + violated: forbiddenTarget, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createChainResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: null, violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const violated = + state.pending !== null && + !targetCorrelates(target, correlation, state.pending, edge); + const activations = activationMatches(activation, edge); + return { + pending: activations[0] ?? null, + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated && state.pending === null, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotChainResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ pending: null, violated: false }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const violated = + state.pending !== null && + targetCorrelates(target, correlation, state.pending, edge); + const activations = activationMatches(activation, edge); + return { + pending: activations[0] ?? null, + violated, + }; + }, + status: (state) => ({ + viable: !state.violated, + accepting: !state.violated, + }), + stateKey: canonicalMonitorStateKey, + }; +} + +export function createAlternateResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + pending: null, + possibleTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + const fulfilled = + state.pending === null || + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + pending: newActivations[0], + possibleTargets: [], + violated: !fulfilled, + }; + } + + if (state.pending !== null && groupMatches(between, edge)) { + return { ...state, violated: true }; + } + + return state.pending !== null && groupMatches(target, edge) + ? { + ...state, + possibleTargets: [...state.possibleTargets, edge], + } + : state; + }, + status: (state) => { + const pendingFulfilled = + state.pending === null || + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + viable: !state.violated, + accepting: !state.violated && pendingFulfilled, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} + +export function createNotAlternateResponseMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return { + initialState: () => ({ + pending: null, + possibleTargets: [], + violated: false, + }), + advance: (state, edge) => { + if (state.violated) { + return state; + } + + const newActivations = activationMatches(activation, edge); + if (newActivations.length > 0) { + const forbiddenSequenceCompleted = + state.pending !== null && + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + pending: newActivations[0], + possibleTargets: [], + violated: forbiddenSequenceCompleted, + }; + } + + if (state.pending === null) { + return state; + } + + if (groupMatches(target, edge)) { + return { + ...state, + possibleTargets: [...state.possibleTargets, edge], + }; + } + + if (!groupMatches(allowedBetween, edge)) { + return { + pending: null, + possibleTargets: [], + violated: false, + }; + } + + return state; + }, + status: (state) => { + const forbiddenSequenceCompleted = + state.pending !== null && + state.possibleTargets.some((candidate) => + targetCorrelates(target, correlation, state.pending!, candidate), + ); + return { + viable: !state.violated, + accepting: !state.violated && !forbiddenSequenceCompleted, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} diff --git a/frontend/src/graph/declareSuccessionMonitors.test.ts b/frontend/src/graph/declareSuccessionMonitors.test.ts new file mode 100644 index 0000000..2289e5b --- /dev/null +++ b/frontend/src/graph/declareSuccessionMonitors.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import type { DeclarePredicateGroup } from "./declareConstraints"; +import type { DeclareMonitor } from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternateSuccessionMonitor, + createChainSuccessionMonitor, + createNotAlternateSuccessionMonitor, + createNotChainSuccessionMonitor, + createNotSuccessionMonitor, + createSuccessionMonitor, +} from "./declareSuccessionMonitors"; + +const group = (name: string): DeclarePredicateGroup => ({ + relation: "or", + predicates: [ + { transition: { operator: "equals", value: name } }, + ], +}); + +function run( + monitor: DeclareMonitor, + edges: DeclareTransition[], +): State { + return edges.reduce( + (state, edge) => monitor.advance(state, edge), + monitor.initialState(), + ); +} + +function status( + monitor: DeclareMonitor, + edges: DeclareTransition[], +) { + return monitor.status(run(monitor, edges)); +} + +const correlatedActivation: DeclarePredicateGroup = { + relation: "or", + predicates: [ + { + transition: { operator: "equals", value: "A" }, + captures: [ + { alias: "request_id", source: "inputs", path: ["id"] }, + ], + }, + ], +}; + +const correlation = { + type: "comparison" as const, + left: { + kind: "target" as const, + source: "outputs" as const, + path: ["id"], + }, + operator: "=" as const, + right: { kind: "activation" as const, alias: "request_id" }, +}; + +const A = (id: number): DeclareTransition => ({ + transition: "A", + inputs: { id }, +}); +const B = (id: number): DeclareTransition => ({ + transition: "B", + outputs: { id }, +}); +const X: DeclareTransition = { transition: "X" }; +const C: DeclareTransition = { transition: "C" }; + +describe("Succession", () => { + it("combines response and precedence semantics", () => { + const monitor = createSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [])).toEqual({ viable: true, accepting: true }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }])).toEqual({ + viable: true, + accepting: false, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("requires correlation in both directions", () => { + const monitor = createSuccessionMonitor( + correlatedActivation, + group("B"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Not succession", () => { + it("forbids a target after an activation", () => { + const monitor = createNotSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "B" }, { transition: "A" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Chain succession", () => { + it("requires every A-B relationship to be immediate in both directions", () => { + const monitor = createChainSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements negative chain succession", () => { + const monitor = createNotChainSuccessionMonitor(group("A"), group("B")); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); + +describe("Alternate succession", () => { + it("combines alternate response and alternate precedence", () => { + const monitor = createAlternateSuccessionMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }])).toEqual({ + viable: true, + accepting: true, + }); + expect(status(monitor, [{ transition: "A" }, { transition: "A" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, { transition: "B" }, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + }); + + it("implements specialized negative alternate succession", () => { + const monitor = createNotAlternateSuccessionMonitor( + group("A"), + group("B"), + group("C"), + ); + expect(status(monitor, [{ transition: "A" }, C, { transition: "B" }])).toEqual({ + viable: false, + accepting: false, + }); + expect(status(monitor, [{ transition: "A" }, X, { transition: "B" }]).accepting).toBe(true); + }); + + it("preserves correlation in the composed alternate monitors", () => { + const monitor = createAlternateSuccessionMonitor( + correlatedActivation, + group("B"), + group("C"), + correlation, + ); + expect(status(monitor, [A(10), B(10)]).accepting).toBe(true); + expect(status(monitor, [A(10), B(20)])).toEqual({ + viable: false, + accepting: false, + }); + }); +}); diff --git a/frontend/src/graph/declareSuccessionMonitors.ts b/frontend/src/graph/declareSuccessionMonitors.ts new file mode 100644 index 0000000..6fe55b7 --- /dev/null +++ b/frontend/src/graph/declareSuccessionMonitors.ts @@ -0,0 +1,141 @@ +import type { DeclarePredicateGroup } from "./declareConstraints"; +import { + canonicalMonitorStateKey, + type DeclareMonitor, +} from "./declareMonitor"; +import type { DeclareTransition } from "./declarePredicates"; +import { + createAlternatePrecedenceMonitor, + createChainPrecedenceMonitor, + createNotAlternatePrecedenceMonitor, + createNotChainPrecedenceMonitor, + createNotPrecedenceMonitor, + createPrecedenceMonitor, +} from "./declarePrecedenceMonitors"; +import { + createAlternateResponseMonitor, + createChainResponseMonitor, + createNotAlternateResponseMonitor, + createNotChainResponseMonitor, + createNotResponseMonitor, + createResponseMonitor, +} from "./declareResponseMonitors"; +import type { CorrelationCondition } from "./transitionCorrelation"; + +export type CompositeMonitorState = { + left: unknown; + right: unknown; +}; + +function composeMonitors( + left: DeclareMonitor, + right: DeclareMonitor, +): DeclareMonitor { + return { + initialState: () => ({ + left: left.initialState(), + right: right.initialState(), + }), + advance: (state, edge: DeclareTransition) => ({ + left: left.advance(state.left, edge), + right: right.advance(state.right, edge), + }), + status: (state) => { + const leftStatus = left.status(state.left); + const rightStatus = right.status(state.right); + return { + viable: leftStatus.viable && rightStatus.viable, + accepting: leftStatus.accepting && rightStatus.accepting, + }; + }, + stateKey: canonicalMonitorStateKey, + }; +} + +export function createSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createResponseMonitor(activation, target, correlation), + createPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createNotSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotResponseMonitor(activation, target, correlation), + createNotPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createChainSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createChainResponseMonitor(activation, target, correlation), + createChainPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createNotChainSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotChainResponseMonitor(activation, target, correlation), + createNotChainPrecedenceMonitor(activation, target, correlation), + ); +} + +export function createAlternateSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + between: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createAlternateResponseMonitor( + activation, + target, + between, + correlation, + ), + createAlternatePrecedenceMonitor( + activation, + target, + between, + correlation, + ), + ); +} + +export function createNotAlternateSuccessionMonitor( + activation: DeclarePredicateGroup, + target: DeclarePredicateGroup, + allowedBetween: DeclarePredicateGroup, + correlation?: CorrelationCondition, +): DeclareMonitor { + return composeMonitors( + createNotAlternateResponseMonitor( + activation, + target, + allowedBetween, + correlation, + ), + createNotAlternatePrecedenceMonitor( + activation, + target, + allowedBetween, + correlation, + ), + ); +} diff --git a/frontend/src/graph/pathSearch.test.ts b/frontend/src/graph/pathSearch.test.ts index 9f437db..ff95071 100644 --- a/frontend/src/graph/pathSearch.test.ts +++ b/frontend/src/graph/pathSearch.test.ts @@ -329,32 +329,116 @@ describe("findKShortestBoundedPaths", () => { expect(result.stopReason).toBe("cancelled"); }); - it("accepts an empty future constraint model", () => { + it("accepts an empty Declare constraint list", () => { const result = findKShortestBoundedPaths( searchInput( ["A", "B"], [{ id: "ab", source: "A", target: "B" }], - { constraints: { requiredTransitions: [] } }, + { constraints: { declare: [] } }, ), ); - 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" }], - }, + it("filters paths using a Declare Init constraint", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "left", "right", "target"], + [ + { id: "bad-start", source: "source", target: "left", transition: "X" }, + { id: "bad-end", source: "left", target: "target", transition: "B" }, + { id: "good-start", source: "source", target: "right", transition: "A" }, + { id: "good-end", source: "right", target: "target", transition: "B" }, + ], + { + requestedPathCount: 2, + constraints: { + declare: [{ + id: "starts-with-a", + template: "init", + enabled: true, + activation: { + relation: "or", + predicates: [{ transition: { operator: "equals", value: "A" } }], + }, + }], }, - ), + }, + ), + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["good-start", "good-end"], + ]); + }); + + it("continues beyond an early target while Response is pending", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "target", "middle"], + [ + { id: "activate", source: "source", target: "target", transition: "A" }, + { id: "fulfil", source: "target", target: "middle", transition: "B" }, + { id: "return", source: "middle", target: "target", transition: "X" }, + ], + { + targetNodeId: "target", + requestedPathCount: 1, + maximumVisitsPerState: 2, + constraints: { + declare: [{ + id: "a-responded-by-b", + template: "response", + enabled: true, + activation: { relation: "or", predicates: [{ transition: { operator: "equals", value: "A" } }] }, + target: { relation: "or", predicates: [{ transition: { operator: "equals", value: "B" } }] }, + }], + }, + }, + ), + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["activate", "fulfil", "return"], + ]); + }); + + it("uses transition data for correlated Response search", () => { + const result = findKShortestBoundedPaths( + searchInput( + ["source", "a", "wrong", "target"], + [ + { id: "submit", source: "source", target: "a", transition: "Submit", inputs: { id: 42 } }, + { id: "wrong", source: "a", target: "wrong", transition: "Complete", outputs: { id: 57 } }, + { id: "right", source: "wrong", target: "target", transition: "Complete", outputs: { id: 42 } }, + ], + { + requestedPathCount: 1, + constraints: { + declare: [{ + id: "same-request", + template: "response", + enabled: true, + activation: { + relation: "or", + predicates: [{ + transition: { operator: "equals", value: "Submit" }, + captures: [{ alias: "request_id", source: "inputs", path: ["id"] }], + }], + }, + target: { relation: "or", predicates: [{ transition: { operator: "equals", value: "Complete" } }] }, + correlation: { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }], + }, + }, ), - ).toThrow("Transition constraints are not implemented yet."); + ); + expect(result.paths.map((path) => path.edgeIds)).toEqual([ + ["submit", "wrong", "right"], + ]); }); it("rejects invalid search parameters", () => { @@ -474,4 +558,161 @@ describe("findKShortestBoundedPaths", () => { expect(result.paths).toHaveLength(1); expect(result.paths[0].edgeIds).toHaveLength(nodeCount - 1); }); + describe("optional target constraint-satisfaction mode", () => { + const responseConstraint = { + id: "response-a-b", + template: "response" as const, + enabled: true, + activation: { + relation: "or" as const, + predicates: [ + { transition: { operator: "equals" as const, value: "A" } }, + ], + }, + target: { + relation: "or" as const, + predicates: [ + { transition: { operator: "equals" as const, value: "B" } }, + ], + }, + }; + + it("finds the shortest endpoint where an exercised Response accepts", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source", "after-a", "satisfied", "later"], + edges: [ + { id: "a", source: "source", target: "after-a", transition: "A" }, + { id: "b", source: "after-a", target: "satisfied", transition: "B" }, + { id: "later", source: "satisfied", target: "later", transition: "X" }, + ], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { declare: [responseConstraint] }, + }); + + expect(result.paths).toEqual([ + { + startNodeId: "source", + endNodeId: "satisfied", + edgeIds: ["a", "b"], + }, + ]); + }); + + it("rejects vacuous Response satisfaction when exercise is required", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source", "unrelated"], + edges: [ + { id: "x", source: "source", target: "unrelated", transition: "X" }, + ], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { declare: [responseConstraint] }, + }); + + expect(result.paths).toEqual([]); + expect(result.exhausted).toBe(true); + }); + + it("allows vacuous satisfaction when exercise is explicitly disabled", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source", "unrelated"], + edges: [ + { id: "x", source: "source", target: "unrelated", transition: "X" }, + ], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requireConstraintExercise: false, + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { declare: [responseConstraint] }, + }); + + expect(result.paths).toEqual([ + { startNodeId: "source", endNodeId: "source", edgeIds: [] }, + ]); + }); + + it("does not treat only a Precedence activation as exercise", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source", "after-a"], + edges: [{ id: "a", source: "source", target: "after-a", transition: "A" }], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { + declare: [{ + id: "a-before-b", + template: "precedence", + enabled: true, + activation: { relation: "or", predicates: [{ transition: { operator: "equals", value: "A" } }] }, + target: { relation: "or", predicates: [{ transition: { operator: "equals", value: "B" } }] }, + }], + }, + }); + + expect(result.paths).toEqual([]); + }); + + it("accepts Exactly 0 without artificial exercise", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source"], + edges: [], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { + declare: [{ + id: "no-a", + template: "exactly", + enabled: true, + count: 0, + activation: { relation: "or", predicates: [{ transition: { operator: "equals", value: "A" } }] }, + }], + }, + }); + + expect(result.paths).toEqual([ + { startNodeId: "source", endNodeId: "source", edgeIds: [] }, + ]); + }); + + it("requires at least one enabled constraint without a target", () => { + expect(() => + findKShortestBoundedPaths({ + nodeIds: ["source"], + edges: [], + sourceNodeId: "source", + endpointMode: "constraint-satisfaction", + requestedPathCount: 1, + maximumVisitsPerState: 1, + constraints: { declare: [] }, + }), + ).toThrow( + "At least one enabled Declare constraint is required when no target state is specified.", + ); + }); + + it("keeps specific-target semantics and reports the actual endpoint", () => { + const result = findKShortestBoundedPaths({ + nodeIds: ["source", "target"], + edges: [{ id: "edge", source: "source", target: "target" }], + sourceNodeId: "source", + targetNodeId: "target", + requestedPathCount: 1, + maximumVisitsPerState: 1, + }); + + expect(result.paths).toEqual([ + { startNodeId: "source", edgeIds: ["edge"] }, + ]); + }); + }); + }); diff --git a/frontend/src/graph/pathSearch.ts b/frontend/src/graph/pathSearch.ts index 3ae9f73..9c8302e 100644 --- a/frontend/src/graph/pathSearch.ts +++ b/frontend/src/graph/pathSearch.ts @@ -1,3 +1,15 @@ +import type { DeclareConstraint } from "./declareConstraints"; +import { + advanceMonitorSet, + createMonitorSet, + getMonitorSetStatus, + type MonitorSetEntry, +} from "./declareMonitor"; +import { + compileDeclareConstraints, + type CompiledDeclareConstraint, +} from "./declareMonitorFactory"; + export type PathSearchEdge = { id: string; source: string; @@ -7,28 +19,21 @@ export type PathSearchEdge = { 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[]; + declare?: DeclareConstraint[]; }; +export type PathSearchEndpointMode = + | "specific-target" + | "constraint-satisfaction"; + export type PathSearchInput = { nodeIds: string[]; edges: PathSearchEdge[]; sourceNodeId: string; - targetNodeId: string; + targetNodeId?: string; + endpointMode?: PathSearchEndpointMode; + requireConstraintExercise?: boolean; requestedPathCount: number; maximumVisitsPerState: number; constraints?: PathConstraints; @@ -36,6 +41,7 @@ export type PathSearchInput = { export type BoundedPath = { startNodeId: string; + endNodeId?: string; edgeIds: string[]; }; @@ -67,17 +73,14 @@ type NormalizedTopology = { incomingNodeIdsByNodeId: Map; }; -type ConstraintProgress = { - nextRequiredTransitionIndex: number; -}; - type SearchCandidate = { currentNodeId: string; parent: SearchCandidate | null; incomingEdgeId: string | null; depth: number; estimatedTotalCost: number; - constraintProgress: ConstraintProgress; + monitorEntries: MonitorSetEntry[]; + exercisedConstraintIds: ReadonlySet; insertionSequence: number; }; @@ -191,8 +194,23 @@ function buildTopology(input: PathSearchInput): NormalizedTopology { 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.`); + const endpointMode = input.endpointMode ?? + (input.targetNodeId === undefined + ? "constraint-satisfaction" + : "specific-target"); + + if (endpointMode === "specific-target") { + if (input.targetNodeId === undefined || input.targetNodeId.length === 0) { + throw new Error("A target state is required in specific-target mode."); + } + + if (!knownNodeIds.has(input.targetNodeId)) { + throw new Error(`Target state ${input.targetNodeId} does not exist.`); + } + } else if (input.targetNodeId !== undefined) { + throw new Error( + "Target state must be omitted in constraint-satisfaction mode.", + ); } if (!Number.isInteger(input.requestedPathCount) || input.requestedPathCount < 1) { @@ -206,12 +224,6 @@ function buildTopology(input: PathSearchInput): NormalizedTopology { 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(); @@ -296,16 +308,6 @@ function validateResourceLimit( 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, @@ -342,6 +344,44 @@ function createPathKey(edgeIds: string[]): string { return JSON.stringify(edgeIds); } +function resolveEndpointMode(input: PathSearchInput): PathSearchEndpointMode { + return input.endpointMode ?? + (input.targetNodeId === undefined + ? "constraint-satisfaction" + : "specific-target"); +} + +function updateExercisedConstraintIds( + compiledConstraints: readonly CompiledDeclareConstraint[], + edge: PathSearchEdge, + exercisedConstraintIds: ReadonlySet, +): ReadonlySet { + let updated: Set | null = null; + + for (const constraint of compiledConstraints) { + if ( + !exercisedConstraintIds.has(constraint.id) && + constraint.isExercisedBy(edge) + ) { + updated ??= new Set(exercisedConstraintIds); + updated.add(constraint.id); + } + } + + return updated ?? exercisedConstraintIds; +} + +function areRequiredConstraintsExercised( + compiledConstraints: readonly CompiledDeclareConstraint[], + exercisedConstraintIds: ReadonlySet, + required: boolean, +): boolean { + return !required || compiledConstraints.every( + (constraint) => + !constraint.requiresExercise || exercisedConstraintIds.has(constraint.id), + ); +} + /** * Finds up to K shortest unique paths with bounded state visits. * @@ -354,10 +394,11 @@ export function findKShortestBoundedPaths( options: PathSearchOptions = {}, ): PathSearchResult { const topology = buildTopology(input); - const distancesToTarget = computeDistancesToTarget( - topology, - input.targetNodeId, - ); + const endpointMode = resolveEndpointMode(input); + const distancesToTarget = + endpointMode === "specific-target" && input.targetNodeId !== undefined + ? computeDistancesToTarget(topology, input.targetNodeId) + : new Map(); const maximumExpandedCandidates = validateResourceLimit( options.maximumExpandedCandidates, DEFAULT_MAXIMUM_EXPANDED_CANDIDATES, @@ -369,6 +410,23 @@ export function findKShortestBoundedPaths( "Maximum queued candidates", ); + const compiledConstraints = compileDeclareConstraints( + input.constraints?.declare ?? [], + ); + const initialMonitorEntries = createMonitorSet(compiledConstraints); + const requireConstraintExercise = + endpointMode === "constraint-satisfaction" && + (input.requireConstraintExercise ?? true); + + if ( + endpointMode === "constraint-satisfaction" && + initialMonitorEntries.length === 0 + ) { + throw new Error( + "At least one enabled Declare constraint is required when no target state is specified.", + ); + } + const queue = new CandidateMinHeap(); let nextInsertionSequence = 1; let expandedCandidateCount = 0; @@ -382,10 +440,11 @@ export function findKShortestBoundedPaths( incomingEdgeId: null, depth: 0, estimatedTotalCost: - distancesToTarget.get(input.sourceNodeId) ?? Number.POSITIVE_INFINITY, - constraintProgress: { - nextRequiredTransitionIndex: 0, - }, + endpointMode === "specific-target" + ? distancesToTarget.get(input.sourceNodeId) ?? Number.POSITIVE_INFINITY + : 0, + monitorEntries: initialMonitorEntries, + exercisedConstraintIds: new Set(), insertionSequence: 0, }); @@ -412,34 +471,62 @@ export function findKShortestBoundedPaths( expandedCandidateCount += 1; const isZeroTransitionSourceTargetPath = candidate.depth === 0 && + endpointMode === "specific-target" && input.sourceNodeId === input.targetNodeId; + const endpointMatches = + endpointMode === "constraint-satisfaction" || + candidate.currentNodeId === input.targetNodeId; + + if (endpointMatches) { + const monitorStatus = getMonitorSetStatus(candidate.monitorEntries); + const exerciseSatisfied = areRequiredConstraintsExercised( + compiledConstraints, + candidate.exercisedConstraintIds, + requireConstraintExercise, + ); - 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; + if (monitorStatus.accepting && exerciseSatisfied) { + const edgeIds = reconstructEdgeIds(candidate); + const pathKey = createPathKey(edgeIds); + + if (!emittedPathKeys.has(pathKey)) { + emittedPathKeys.add(pathKey); + paths.push({ + startNodeId: input.sourceNodeId, + ...(endpointMode === "constraint-satisfaction" + ? { endNodeId: candidate.currentNodeId } + : {}), + edgeIds, + }); + } + + // A non-empty specific-target arrival ends at that target. In + // constraint-satisfaction mode the candidate remains expandable so + // additional, longer satisfying paths can still be enumerated. + if ( + endpointMode === "specific-target" && + !isZeroTransitionSourceTargetPath + ) { + continue; + } } + // A specific-target arrival with pending obligations remains expandable + // because a later transition may satisfy them before returning. } const outgoingEdges = topology.outgoingEdgesByNodeId.get(candidate.currentNodeId) ?? []; for (const edge of outgoingEdges) { - const remainingDistance = distancesToTarget.get(edge.target); + const remainingDistance = + endpointMode === "specific-target" + ? distancesToTarget.get(edge.target) + : 0; - if (remainingDistance === undefined) { + if ( + endpointMode === "specific-target" && + remainingDistance === undefined + ) { continue; } @@ -454,23 +541,30 @@ export function findKShortestBoundedPaths( continue; } - const nextConstraintProgress = advanceConstraintProgress( - candidate.constraintProgress, + const nextMonitorEntries = advanceMonitorSet( + candidate.monitorEntries, edge, - input.constraints, ); - if (nextConstraintProgress === null) { + if (!getMonitorSetStatus(nextMonitorEntries).viable) { continue; } + const nextExercisedConstraintIds = updateExercisedConstraintIds( + compiledConstraints, + edge, + candidate.exercisedConstraintIds, + ); + queue.push({ currentNodeId: edge.target, parent: candidate, incomingEdgeId: edge.id, depth: candidate.depth + 1, - estimatedTotalCost: candidate.depth + 1 + remainingDistance, - constraintProgress: nextConstraintProgress, + estimatedTotalCost: + candidate.depth + 1 + (remainingDistance ?? 0), + monitorEntries: nextMonitorEntries, + exercisedConstraintIds: nextExercisedConstraintIds, insertionSequence: nextInsertionSequence, }); nextInsertionSequence += 1; diff --git a/frontend/src/graph/transitionCatalog.test.ts b/frontend/src/graph/transitionCatalog.test.ts new file mode 100644 index 0000000..2b201c0 --- /dev/null +++ b/frontend/src/graph/transitionCatalog.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + buildTransitionCatalogue, + filterTransitionOptions, +} from "./transitionCatalog"; + +describe("transition catalogue", () => { + it("deduplicates names and counts occurrences", () => { + expect(buildTransitionCatalogue(["Approve", "Reject", "Approve", " "])).toEqual([ + { name: "Approve", occurrenceCount: 2 }, + { name: "Reject", occurrenceCount: 1 }, + ]); + }); + + it("filters case-insensitively by substring", () => { + const options = buildTransitionCatalogue([ + "Root_SubmitLoginAttempt", + "Root_ReceiveResult", + "Other_SubmitLoginAttempt", + ]); + + expect(filterTransitionOptions(options, "submit").map((option) => option.name)).toEqual([ + "Other_SubmitLoginAttempt", + "Root_SubmitLoginAttempt", + ]); + }); + + it("ranks an exact match before more frequent partial matches", () => { + const options = buildTransitionCatalogue(["Login", "LoginAttempt", "LoginAttempt"]); + expect(filterTransitionOptions(options, "login")[0].name).toBe("Login"); + }); +}); diff --git a/frontend/src/graph/transitionCatalog.ts b/frontend/src/graph/transitionCatalog.ts new file mode 100644 index 0000000..86a253e --- /dev/null +++ b/frontend/src/graph/transitionCatalog.ts @@ -0,0 +1,49 @@ +export type TransitionOption = { + name: string; + occurrenceCount: number; +}; + +export function buildTransitionCatalogue( + transitions: readonly (string | null | undefined)[], +): TransitionOption[] { + const counts = new Map(); + + transitions.forEach((transition) => { + const name = transition?.trim(); + if (!name) return; + counts.set(name, (counts.get(name) ?? 0) + 1); + }); + + return [...counts.entries()] + .map(([name, occurrenceCount]) => ({ name, occurrenceCount })) + .sort( + (left, right) => + right.occurrenceCount - left.occurrenceCount || + left.name.localeCompare(right.name), + ); +} + +export function filterTransitionOptions( + options: readonly TransitionOption[], + query: string, + limit = 50, +): TransitionOption[] { + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filtered = normalizedQuery + ? options.filter((option) => + option.name.toLocaleLowerCase().includes(normalizedQuery), + ) + : [...options]; + + return filtered + .sort((left, right) => { + const leftExact = left.name.toLocaleLowerCase() === normalizedQuery; + const rightExact = right.name.toLocaleLowerCase() === normalizedQuery; + if (leftExact !== rightExact) return leftExact ? -1 : 1; + return ( + right.occurrenceCount - left.occurrenceCount || + left.name.localeCompare(right.name) + ); + }) + .slice(0, Math.max(0, limit)); +} diff --git a/frontend/src/graph/transitionConditionEditorModel.test.ts b/frontend/src/graph/transitionConditionEditorModel.test.ts new file mode 100644 index 0000000..a9d7fb4 --- /dev/null +++ b/frontend/src/graph/transitionConditionEditorModel.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; +import { + countArrayLevels, + createDefaultArrayAccesses, + createFlatTransitionCondition, + createValueCondition, + formatConfiguredPath, + inferConditionValueType, + parseConditionValue, + readFlatTransitionConditions, + type FlatTransitionCondition, +} from "./transitionConditionEditorModel"; + +function flat(overrides: Partial = {}): FlatTransitionCondition { + return { + source: "outputs", + path: ["matrix", "[]", "[]", "value"], + arrayAccesses: [ + { mode: "contains-item" }, + { mode: "contains-item" }, + ], + operator: "=", + value: 42, + ...overrides, + }; +} + +describe("transition condition editor model", () => { + it("creates and reads ordinary source comparisons", () => { + const item = flat({ source: "inputs", path: ["request", "priority"], arrayAccesses: [], operator: ">=", value: 5 }); + const condition = createFlatTransitionCondition([item]); + expect(condition).toEqual({ type: "source", source: "inputs", condition: { type: "comparison", path: ["request", "priority"], operator: ">=", value: 5 } }); + expect(readFlatTransitionConditions(condition)).toEqual([item]); + }); + + it("supports arbitrary all-indexed array depth", () => { + const item = flat({ + path: ["tensor", "[]", "[]", "[]", "value"], + arrayAccesses: [ + { mode: "indexed-item", index: 1 }, + { mode: "indexed-item", index: 2 }, + { mode: "indexed-item", index: 3 }, + ], + }); + expect(createValueCondition(item)).toEqual({ type: "comparison", path: ["tensor", 1, 2, 3, "value"], operator: "=", value: 42 }); + expect(readFlatTransitionConditions(createFlatTransitionCondition([item]))).toEqual([item]); + }); + + it("supports arbitrary all-existential array depth", () => { + const item = flat({ path: ["tensor", "[]", "[]", "[]", "value"], arrayAccesses: [ + { mode: "contains-item" }, { mode: "contains-item" }, { mode: "contains-item" }, + ] }); + expect(createValueCondition(item)).toEqual({ + type: "contains-item", path: ["tensor"], condition: { + type: "contains-item", path: [], condition: { + type: "contains-item", path: [], condition: { + type: "comparison", path: ["value"], operator: "=", value: 42, + }, + }, + }, + }); + expect(readFlatTransitionConditions(createFlatTransitionCondition([item]))).toEqual([item]); + }); + + it("supports mixed indexed and existential levels", () => { + const item = flat({ path: ["tensor", "[]", "rows", "[]", "[]", "value"], arrayAccesses: [ + { mode: "indexed-item", index: 2 }, + { mode: "contains-item" }, + { mode: "indexed-item", index: 4 }, + ] }); + expect(createValueCondition(item)).toEqual({ + type: "contains-item", path: ["tensor", 2, "rows"], condition: { + type: "comparison", path: [4, "value"], operator: "=", value: 42, + }, + }); + expect(readFlatTransitionConditions(createFlatTransitionCondition([item]))).toEqual([item]); + }); + + it("supports arrays of primitive values", () => { + const item = flat({ path: ["values", "[]"], arrayAccesses: [{ mode: "contains-item" }], value: "ready" }); + expect(createValueCondition(item)).toEqual({ type: "contains-item", path: ["values"], condition: { type: "comparison", path: [], operator: "=", value: "ready" } }); + }); + + it("counts levels, creates defaults, and formats mixed paths", () => { + const path = ["tensor", "[]", "rows", "[]", "value"] as const; + expect(countArrayLevels(path)).toBe(2); + expect(createDefaultArrayAccesses(path)).toEqual([{ mode: "contains-item" }, { mode: "contains-item" }]); + expect(formatConfiguredPath("outputs", path, [{ mode: "indexed-item", index: 2 }, { mode: "contains-item" }])).toBe("outputs.tensor[2].rows[*].value"); + }); + + it("rejects incomplete or invalid array-level configuration", () => { + expect(() => createValueCondition(flat({ arrayAccesses: [{ mode: "contains-item" }] }))).toThrow("Configure all 2 array levels."); + expect(() => createValueCondition(flat({ arrayAccesses: [{ mode: "indexed-item", index: -1 }, { mode: "contains-item" }] }))).toThrow("Enter a non-negative array index."); + }); + + it("creates a flat AND group and rejects unsupported OR groups", () => { + const condition = createFlatTransitionCondition([flat(), flat({ path: ["status"], arrayAccesses: [], value: "ok" })]); + expect(condition).toMatchObject({ type: "group", operator: "and" }); + expect(readFlatTransitionConditions({ type: "group", operator: "or", conditions: [] })).toBeNull(); + }); + + it("parses strict typed values and infers scalar types", () => { + expect(parseConditionValue("string", "42")).toBe("42"); + expect(parseConditionValue("number", "42")).toBe(42); + expect(parseConditionValue("boolean", "true")).toBe(true); + expect(parseConditionValue("null", "ignored")).toBeNull(); + expect(() => parseConditionValue("number", "abc")).toThrow("Enter a valid number."); + expect(inferConditionValueType(["number"])).toBe("number"); + expect(inferConditionValueType(["object"])).toBe("string"); + }); + + it("creates empty condition lists as undefined", () => { + expect(createFlatTransitionCondition([])).toBeUndefined(); + expect(readFlatTransitionConditions(undefined)).toEqual([]); + }); + + it.each(["exists", "does-not-exist"] as const)( + "round-trips %s without a value", + (operator) => { + const item = flat({ + path: ["matrix", "[]", "status"], + arrayAccesses: [{ mode: "indexed-item", index: 3 }], + operator, + value: "discarded", + }); + const condition = createFlatTransitionCondition([item]); + expect(condition).toEqual({ + type: "source", + source: "outputs", + condition: { + type: "comparison", + path: ["matrix", 3, "status"], + operator, + }, + }); + const { value: _discarded, ...expected } = item; + expect(readFlatTransitionConditions(condition)).toEqual([expected]); + }, + ); + + it("preserves mixed sources and repeated fields in one AND group", () => { + const items = [ + flat({ + source: "inputs", + path: ["request", "status"], + arrayAccesses: [], + value: "ready", + }), + flat({ + source: "outputs", + path: ["request", "status"], + arrayAccesses: [], + operator: "!=", + value: "blocked", + }), + flat({ + source: "outputs", + path: ["request", "status"], + arrayAccesses: [], + operator: "exists", + value: undefined, + }), + ]; + expect(readFlatTransitionConditions(createFlatTransitionCondition(items))).toEqual(items); + }); + + it("round-trips mixed traversal through every multidimensional path level", () => { + const item = flat({ + path: ["tensor", "[]", "rows", "[]", "cells", "[]", "value"], + arrayAccesses: [ + { mode: "contains-item" }, + { mode: "indexed-item", index: 4 }, + { mode: "contains-item" }, + ], + operator: ">=", + value: 9, + }); + expect(readFlatTransitionConditions(createFlatTransitionCondition([item]))).toEqual([item]); + }); + + it("parses all supported primitive values", () => { + expect(parseConditionValue("string", "false")).toBe("false"); + expect(parseConditionValue("number", "-12.5")).toBe(-12.5); + expect(parseConditionValue("boolean", "false")).toBe(false); + expect(parseConditionValue("null", "anything")).toBeNull(); + expect(() => parseConditionValue("boolean", "yes")).toThrow("Choose true or false."); + }); + +}); diff --git a/frontend/src/graph/transitionConditionEditorModel.ts b/frontend/src/graph/transitionConditionEditorModel.ts new file mode 100644 index 0000000..ffb2ef2 --- /dev/null +++ b/frontend/src/graph/transitionConditionEditorModel.ts @@ -0,0 +1,218 @@ +import type { + ComparisonOperator, + DataPathSegment, + DataSource, + JsonPrimitive, + TransitionCondition, + ValueCondition, +} from "./transitionConditions"; + +export const ARRAY_ITEM_SEGMENT = "[]"; +export type ConditionValueType = "string" | "number" | "boolean" | "null"; +export type ArrayAccess = + | { mode: "contains-item" } + | { mode: "indexed-item"; index: number }; + +export type FlatTransitionCondition = { + source: DataSource; + path: DataPathSegment[]; + arrayAccesses: ArrayAccess[]; + operator: ComparisonOperator; + value?: JsonPrimitive; +}; + +function normalizePath(path: readonly DataPathSegment[]): { + path: DataPathSegment[]; + arrayAccesses: ArrayAccess[]; +} { + const arrayAccesses: ArrayAccess[] = []; + const normalized = path.map((segment) => { + if (typeof segment !== "number") return segment; + arrayAccesses.push({ mode: "indexed-item", index: segment }); + return ARRAY_ITEM_SEGMENT; + }); + return { path: normalized, arrayAccesses }; +} + +function readValueCondition(condition: ValueCondition): Omit | null { + if (condition.type === "comparison") { + const normalized = normalizePath(condition.path); + return { + path: normalized.path, + arrayAccesses: normalized.arrayAccesses, + operator: condition.operator, + ...(condition.value !== undefined + ? { value: condition.value as JsonPrimitive } + : {}), + }; + } + + if (condition.type !== "contains-item") return null; + const child = readValueCondition(condition.condition); + if (child === null) return null; + const collection = normalizePath(condition.path); + return { + path: [ + ...collection.path, + ARRAY_ITEM_SEGMENT, + ...child.path, + ], + arrayAccesses: [ + ...collection.arrayAccesses, + { mode: "contains-item" }, + ...child.arrayAccesses, + ], + operator: child.operator, + ...(child.value !== undefined ? { value: child.value } : {}), + }; +} + +export function readFlatTransitionConditions( + condition: TransitionCondition | undefined, +): FlatTransitionCondition[] | null { + if (condition === undefined) return []; + const conditions = condition.type === "group" + ? condition.operator === "and" ? condition.conditions : null + : [condition]; + if (conditions === null) return null; + + const result: FlatTransitionCondition[] = []; + for (const item of conditions) { + if (item.type !== "source") return null; + const flat = readValueCondition(item.condition); + if (flat === null) return null; + result.push({ source: item.source, ...flat }); + } + return result; +} + +function validateArrayAccess(access: ArrayAccess): void { + if ( + access.mode === "indexed-item" && + (!Number.isInteger(access.index) || access.index < 0) + ) { + throw new Error("Enter a non-negative array index."); + } +} + +export function countArrayLevels(path: readonly DataPathSegment[]): number { + return path.filter((segment) => segment === ARRAY_ITEM_SEGMENT).length; +} + +export function createDefaultArrayAccesses( + path: readonly DataPathSegment[], +): ArrayAccess[] { + return Array.from( + { length: countArrayLevels(path) }, + () => ({ mode: "contains-item" }) as ArrayAccess, + ); +} + +export function createValueCondition( + item: FlatTransitionCondition, +): ValueCondition { + const expected = countArrayLevels(item.path); + if (item.arrayAccesses.length !== expected) { + throw new Error(`Configure all ${expected} array levels.`); + } + item.arrayAccesses.forEach(validateArrayAccess); + + const createComparison = (path: DataPathSegment[]): ValueCondition => ({ + type: "comparison", + path, + operator: item.operator, + ...(item.operator === "exists" || item.operator === "does-not-exist" + ? {} + : { value: item.value ?? null }), + }); + + function build( + path: readonly DataPathSegment[], + accesses: readonly ArrayAccess[], + ): ValueCondition { + const markerIndex = path.indexOf(ARRAY_ITEM_SEGMENT); + if (markerIndex < 0) return createComparison([...path]); + + const access = accesses[0]; + if (access === undefined) throw new Error("Configure every array level."); + const before = path.slice(0, markerIndex); + const after = path.slice(markerIndex + 1); + + if (access.mode === "indexed-item") { + return build([...before, access.index, ...after], accesses.slice(1)); + } + return { + type: "contains-item", + path: [...before], + condition: build(after, accesses.slice(1)), + }; + } + + return build(item.path, item.arrayAccesses); +} + +export function createFlatTransitionCondition( + conditions: readonly FlatTransitionCondition[], +): TransitionCondition | undefined { + const sourceConditions: TransitionCondition[] = conditions.map((item) => ({ + type: "source", + source: item.source, + condition: createValueCondition(item), + })); + if (sourceConditions.length === 0) return undefined; + if (sourceConditions.length === 1) return sourceConditions[0]; + return { type: "group", operator: "and", conditions: sourceConditions }; +} + +export function formatConfiguredPath( + source: DataSource, + path: readonly DataPathSegment[], + accesses: readonly ArrayAccess[], +): string { + let result = source; + let level = 0; + for (const segment of path) { + if (segment === ARRAY_ITEM_SEGMENT) { + const access = accesses[level++]; + result += access?.mode === "indexed-item" + ? `[${access.index}]` + : "[*]"; + } else if (typeof segment === "number") { + result += `[${segment}]`; + } else { + result += `.${segment}`; + } + } + return result; +} + +export function parseConditionValue( + valueType: ConditionValueType, + text: string, +): JsonPrimitive { + switch (valueType) { + case "string": return text; + case "number": { + const value = Number(text); + if (!Number.isFinite(value)) throw new Error("Enter a valid number."); + return value; + } + case "boolean": + if (text === "true") return true; + if (text === "false") return false; + throw new Error("Choose true or false."); + case "null": return null; + } +} + +export function inferConditionValueType( + valueTypes: readonly string[], +): ConditionValueType { + if (valueTypes.length === 1) { + const [valueType] = valueTypes; + if (["string", "number", "boolean", "null"].includes(valueType)) { + return valueType as ConditionValueType; + } + } + return "string"; +} diff --git a/frontend/src/graph/transitionConditions.test.ts b/frontend/src/graph/transitionConditions.test.ts new file mode 100644 index 0000000..470064d --- /dev/null +++ b/frontend/src/graph/transitionConditions.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { describeDataPath, evaluateTransitionCondition, matchesPartialObject, resolveDataPath, validateTransitionCondition, type TransitionCondition } from "./transitionConditions"; + +const transition = { + inputs: { request: { id: 42, priority: 7, urgent: false, optional: null, type: "imaging" } }, + outputs: { completed: [{ id: 12, status: "queued" }, { id: 42, status: "ok", details: { duration: 15 } }], tensor: [[[1, 2], [3, 4]], [[5, 6], [7, 8]]], objectTensor: [[[{ code: "deep" }]]] }, +}; + +function sourceCondition(source: "inputs" | "outputs", condition: Extract["condition"]): TransitionCondition { + return { type: "source", source, condition }; +} + +describe("resolveDataPath", () => { + it("resolves nested object properties and zero-based array indexes", () => { + expect(resolveDataPath(transition.outputs, ["completed", 1, "id"])).toEqual({ found: true, value: 42 }); + }); + it("treats negative, out-of-range, and non-array indexes as missing", () => { + expect(resolveDataPath(transition.outputs, ["completed", 7])).toEqual({ found: false, value: undefined }); + expect(resolveDataPath(transition.outputs, ["completed", -1])).toEqual({ found: false, value: undefined }); + expect(resolveDataPath(transition.inputs, ["request", 0])).toEqual({ found: false, value: undefined }); + }); + it("distinguishes a missing field from an existing null value", () => { + expect(resolveDataPath(transition.inputs, ["request", "optional"])).toEqual({ found: true, value: null }); + expect(resolveDataPath(transition.inputs, ["request", "missing"])).toEqual({ found: false, value: undefined }); + }); + it("formats indexed paths for explanations", () => { + expect(describeDataPath("outputs", ["completed", 1, "id"])).toBe("outputs.completed[1].id"); + }); +}); + +describe("matchesPartialObject", () => { + it("matches a recursive object subset while allowing additional fields", () => { + expect(matchesPartialObject(transition.inputs.request, { id: 42 })).toBe(true); + expect(matchesPartialObject(transition.outputs.completed[1], { details: { duration: 15 } })).toBe(true); + }); + it("uses exact semantics for arrays inside partial objects", () => { + expect(matchesPartialObject({ values: [1, 2], extra: true }, { values: [1, 2] })).toBe(true); + expect(matchesPartialObject({ values: [1, 2, 3] }, { values: [1, 2] })).toBe(false); + }); +}); + +describe("evaluateTransitionCondition", () => { + it("supports strict equality and numeric ordering without coercion", () => { + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "comparison", path: ["request", "id"], operator: "=", value: 42 }), transition).matches).toBe(true); + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "comparison", path: ["request", "id"], operator: "=", value: "42" }), transition).matches).toBe(false); + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "comparison", path: ["request", "priority"], operator: ">=", value: 7 }), transition).matches).toBe(true); + }); + it("supports indexed equality and existence", () => { + expect(evaluateTransitionCondition(sourceCondition("outputs", { type: "comparison", path: ["completed", 1, "status"], operator: "=", value: "ok" }), transition).matches).toBe(true); + expect(evaluateTransitionCondition(sourceCondition("outputs", { type: "comparison", path: ["completed", 2], operator: "does-not-exist" }), transition).matches).toBe(true); + expect(evaluateTransitionCondition(sourceCondition("outputs", { type: "comparison", path: ["completed", 1], operator: "exists" }), transition).matches).toBe(true); + }); + it("supports exists without confusing null and missing", () => { + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "comparison", path: ["request", "optional"], operator: "exists" }), transition).matches).toBe(true); + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "comparison", path: ["request", "missing"], operator: "does-not-exist" }), transition).matches).toBe(true); + }); + it("supports partial object matching", () => { + expect(evaluateTransitionCondition(sourceCondition("inputs", { type: "partial-object", path: ["request"], value: { priority: 7, type: "imaging" } }), transition).matches).toBe(true); + }); + it("supports nested AND and OR across inputs and outputs", () => { + const condition: TransitionCondition = { type: "group", operator: "and", conditions: [ + sourceCondition("inputs", { type: "group", operator: "or", conditions: [ + { type: "comparison", path: ["request", "priority"], operator: ">=", value: 10 }, + { type: "comparison", path: ["request", "type"], operator: "=", value: "imaging" }, + ] }), + sourceCondition("outputs", { type: "contains-item", path: ["completed"], condition: { type: "comparison", path: ["status"], operator: "=", value: "ok" } }), + ] }; + expect(evaluateTransitionCondition(condition, transition).matches).toBe(true); + }); + it("supports contains-item with nested conditions relative to each item", () => { + const condition = sourceCondition("outputs", { type: "contains-item", path: ["completed"], condition: { type: "group", operator: "and", conditions: [ + { type: "comparison", path: ["id"], operator: "=", value: 42 }, + { type: "comparison", path: ["status"], operator: "=", value: "ok" }, + { type: "comparison", path: ["details", "duration"], operator: "<=", value: 20 }, + ] } }); + expect(evaluateTransitionCondition(condition, transition)).toEqual({ matches: true, errors: [] }); + }); + it("supports arbitrary-depth indexed array paths", () => { + expect(evaluateTransitionCondition(sourceCondition("outputs", { type: "comparison", path: ["tensor", 1, 0, 1], operator: "=", value: 6 }), transition).matches).toBe(true); + expect(evaluateTransitionCondition(sourceCondition("outputs", { type: "comparison", path: ["tensor", 1, 0, 2], operator: "does-not-exist" }), transition).matches).toBe(true); + }); + it("supports arbitrary-depth nested contains-item conditions", () => { + const condition = sourceCondition("outputs", { + type: "contains-item", path: ["objectTensor"], condition: { + type: "contains-item", path: [], condition: { + type: "contains-item", path: [], condition: { + type: "comparison", path: ["code"], operator: "=", value: "deep", + }, + }, + }, + }); + expect(evaluateTransitionCondition(condition, transition).matches).toBe(true); + }); + it("supports mixed indexed and contains-item array levels", () => { + const condition = sourceCondition("outputs", { + type: "contains-item", path: ["tensor", 1], condition: { + type: "comparison", path: [1], operator: "=", value: 8, + }, + }); + expect(evaluateTransitionCondition(condition, transition).matches).toBe(true); + }); + it("returns validation errors instead of evaluating malformed conditions", () => { + const missingValue = sourceCondition("inputs", { type: "comparison", path: ["request", "id"], operator: "=" }); + expect(validateTransitionCondition(missingValue)).toEqual(["inputs requires a comparison value."]); + expect(evaluateTransitionCondition(missingValue, transition).matches).toBe(false); + }); +}); diff --git a/frontend/src/graph/transitionConditions.ts b/frontend/src/graph/transitionConditions.ts new file mode 100644 index 0000000..20cf475 --- /dev/null +++ b/frontend/src/graph/transitionConditions.ts @@ -0,0 +1,356 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + | JsonPrimitive + | JsonValue[] + | { [key: string]: JsonValue }; + +export type DataPathSegment = string | number; +export type DataSource = "inputs" | "outputs"; +export type ComparisonOperator = + | "=" + | "!=" + | "<" + | "<=" + | ">" + | ">=" + | "exists" + | "does-not-exist"; + +export type ValueCondition = + | { + type: "comparison"; + path: DataPathSegment[]; + operator: ComparisonOperator; + value?: JsonValue; + } + | { + type: "partial-object"; + path: DataPathSegment[]; + value: { [key: string]: JsonValue }; + } + | { + type: "contains-item"; + path: DataPathSegment[]; + condition: ValueCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: ValueCondition[]; + }; + +export type TransitionCondition = + | { + type: "source"; + source: DataSource; + condition: ValueCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: TransitionCondition[]; + }; + +export type TransitionData = { + inputs?: unknown; + outputs?: unknown; +}; + +export type ConditionEvaluation = { + matches: boolean; + errors: string[]; +}; + +type ResolvedValue = { + found: boolean; + value: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Resolves object-property and zero-based array-index path segments. + * Missing properties, negative indexes, out-of-range indexes, and indexing a + * non-array value are all reported as missing. An existing null value remains + * distinct from a missing value. + */ +export function resolveDataPath( + root: unknown, + path: readonly DataPathSegment[], +): ResolvedValue { + let current = root; + + for (const segment of path) { + if (typeof segment === "number") { + if ( + !Number.isInteger(segment) || + !Array.isArray(current) || + segment < 0 || + segment >= current.length + ) { + return { found: false, value: undefined }; + } + + current = current[segment]; + continue; + } + + if ( + !isRecord(current) || + !Object.prototype.hasOwnProperty.call(current, segment) + ) { + return { found: false, value: undefined }; + } + + current = current[segment]; + } + + return { found: true, value: current }; +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ); + } + + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(right, key) && + deepEqual(left[key], right[key]), + ) + ); + } + + return false; +} + +export function matchesPartialObject( + actual: unknown, + expected: Record, +): boolean { + if (!isRecord(actual)) return false; + + return Object.entries(expected).every(([key, expectedValue]) => { + if (!Object.prototype.hasOwnProperty.call(actual, key)) return false; + + const actualValue = actual[key]; + + if (isRecord(expectedValue)) { + return matchesPartialObject( + actualValue, + expectedValue as Record, + ); + } + + return deepEqual(actualValue, expectedValue); + }); +} + +function formatPath(path: readonly DataPathSegment[]): string { + if (path.length === 0) return ""; + + return path + .map((segment) => + typeof segment === "number" ? `[${segment}]` : segment, + ) + .join(".") + .replace(/\.\[/g, "["); +} + +function validateValueCondition( + condition: ValueCondition, + location: string, +): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + + return condition.conditions.flatMap((child, index) => + validateValueCondition(child, `${location}.conditions[${index}]`), + ); + } + + if (condition.type === "contains-item") { + return validateValueCondition(condition.condition, `${location}.condition`); + } + + if (condition.type === "comparison") { + const requiresValue = !["exists", "does-not-exist"].includes( + condition.operator, + ); + + if (requiresValue && condition.value === undefined) { + return [`${location} requires a comparison value.`]; + } + + if (!requiresValue && condition.value !== undefined) { + return [ + `${location} must not define a value for ${condition.operator}.`, + ]; + } + } + + return []; +} + +export function validateTransitionCondition( + condition: TransitionCondition, +): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return [ + "The transition condition group must contain at least one condition.", + ]; + } + + return condition.conditions.flatMap((child, index) => + validateTransitionConditionAt(child, `conditions[${index}]`), + ); + } + + return validateValueCondition(condition.condition, condition.source); +} + +function validateTransitionConditionAt( + condition: TransitionCondition, + location: string, +): string[] { + if (condition.type === "group") { + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + + return condition.conditions.flatMap((child, index) => + validateTransitionConditionAt(child, `${location}.conditions[${index}]`), + ); + } + + return validateValueCondition( + condition.condition, + `${location}.${condition.source}`, + ); +} + +function evaluateComparison( + condition: Extract, + root: unknown, +): boolean { + const resolved = resolveDataPath(root, condition.path); + + if (condition.operator === "exists") return resolved.found; + if (condition.operator === "does-not-exist") return !resolved.found; + if (!resolved.found) return false; + if (condition.operator === "=") { + return deepEqual(resolved.value, condition.value); + } + if (condition.operator === "!=") { + return !deepEqual(resolved.value, condition.value); + } + + if ( + typeof resolved.value !== "number" || + typeof condition.value !== "number" + ) { + return false; + } + + switch (condition.operator) { + case "<": + return resolved.value < condition.value; + case "<=": + return resolved.value <= condition.value; + case ">": + return resolved.value > condition.value; + case ">=": + return resolved.value >= condition.value; + } +} + +function evaluateValueCondition( + condition: ValueCondition, + root: unknown, +): boolean { + switch (condition.type) { + case "comparison": + return evaluateComparison(condition, root); + case "partial-object": { + const resolved = resolveDataPath(root, condition.path); + return ( + resolved.found && matchesPartialObject(resolved.value, condition.value) + ); + } + case "contains-item": { + const resolved = resolveDataPath(root, condition.path); + return ( + resolved.found && + Array.isArray(resolved.value) && + resolved.value.some((item) => + evaluateValueCondition(condition.condition, item), + ) + ); + } + case "group": + return condition.operator === "and" + ? condition.conditions.every((child) => + evaluateValueCondition(child, root), + ) + : condition.conditions.some((child) => + evaluateValueCondition(child, root), + ); + } +} + +function evaluateValidatedTransitionCondition( + condition: TransitionCondition, + transition: TransitionData, +): boolean { + if (condition.type === "source") { + return evaluateValueCondition( + condition.condition, + transition[condition.source], + ); + } + + return condition.operator === "and" + ? condition.conditions.every((child) => + evaluateValidatedTransitionCondition(child, transition), + ) + : condition.conditions.some((child) => + evaluateValidatedTransitionCondition(child, transition), + ); +} + +export function evaluateTransitionCondition( + condition: TransitionCondition, + transition: TransitionData, +): ConditionEvaluation { + const errors = validateTransitionCondition(condition); + + if (errors.length > 0) return { matches: false, errors }; + + return { + matches: evaluateValidatedTransitionCondition(condition, transition), + errors: [], + }; +} + +export function describeDataPath( + source: DataSource, + path: readonly DataPathSegment[], +): string { + return `${source}.${formatPath(path)}`; +} diff --git a/frontend/src/graph/transitionCorrelation.test.ts b/frontend/src/graph/transitionCorrelation.test.ts new file mode 100644 index 0000000..ca7b51a --- /dev/null +++ b/frontend/src/graph/transitionCorrelation.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import { + captureActivationValues, + evaluateCorrelationCondition, + validateCaptureDefinitions, + validateCorrelationCondition, + type CorrelationCondition, +} from "./transitionCorrelation"; + +const activation = { + inputs: { + request: { + id: 42, + priority: 7, + amount: 100, + }, + }, +}; + +const target = { + outputs: { + completed: [ + { id: 12, status: "queued", amount: 125 }, + { id: 42, status: "ok", amount: 80 }, + ], + }, +}; + +describe("captureActivationValues", () => { + it("captures referenced activation values under readable aliases", () => { + expect( + captureActivationValues( + [ + { + alias: "request_id", + source: "inputs", + path: ["request", "id"], + }, + { + alias: "request_amount", + source: "inputs", + path: ["request", "amount"], + }, + ], + activation, + ), + ).toEqual({ + bindings: { + request_id: 42, + request_amount: 100, + }, + errors: [], + }); + }); + + it("rejects invalid and duplicate aliases", () => { + expect( + validateCaptureDefinitions([ + { alias: "1bad", source: "inputs", path: [] }, + { alias: "valid", source: "inputs", path: [] }, + { alias: "valid", source: "outputs", path: [] }, + ]), + ).toEqual([ + "captures[0].alias must start with a letter or underscore and contain only letters, numbers, and underscores.", + "Duplicate capture alias: $valid.", + ]); + }); + + it("fails clearly when a capture path is missing", () => { + expect( + captureActivationValues( + [ + { + alias: "missing_id", + source: "inputs", + path: ["request", "missing"], + }, + ], + activation, + ), + ).toEqual({ + bindings: {}, + errors: ["Capture $missing_id could not resolve its data path."], + }); + }); +}); + +describe("evaluateCorrelationCondition", () => { + const bindings = { + request_id: 42, + request_amount: 100, + }; + + it("compares a target field with a captured activation value", () => { + const condition: CorrelationCondition = { + type: "comparison", + left: { + kind: "target", + source: "outputs", + path: ["completed", 1, "id"], + }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings)).toEqual({ + matches: true, + errors: [], + }); + }); + + it("supports correlated array item matching", () => { + const condition: CorrelationCondition = { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "group", + operator: "and", + conditions: [ + { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + { + type: "comparison", + left: { kind: "item", path: ["status"] }, + operator: "=", + right: { kind: "literal", value: "ok" }, + }, + { + type: "comparison", + left: { kind: "item", path: ["amount"] }, + operator: "<", + right: { + kind: "activation", + alias: "request_amount", + }, + }, + ], + }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + true, + ); + }); + + it("evaluates each pending activation independently", () => { + const condition: CorrelationCondition = { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }; + + expect( + evaluateCorrelationCondition(condition, target, { request_id: 42 }).matches, + ).toBe(true); + expect( + evaluateCorrelationCondition(condition, target, { request_id: 57 }).matches, + ).toBe(false); + }); + + it("supports nested AND and OR correlation expressions", () => { + const condition: CorrelationCondition = { + type: "group", + operator: "or", + conditions: [ + { + type: "comparison", + left: { kind: "target", source: "outputs", path: ["missing"] }, + operator: "=", + right: { kind: "literal", value: true }, + }, + { + type: "contains-item", + source: "outputs", + path: ["completed"], + condition: { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "activation", alias: "request_id" }, + }, + }, + ], + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + true, + ); + }); + + it("distinguishes missing target values from null values", () => { + const targetWithNull = { outputs: { result: null } }; + const exists: CorrelationCondition = { + type: "reference-exists", + reference: { kind: "target", source: "outputs", path: ["result"] }, + exists: true, + }; + const missing: CorrelationCondition = { + type: "reference-exists", + reference: { kind: "target", source: "outputs", path: ["missing"] }, + exists: false, + }; + + expect( + evaluateCorrelationCondition(exists, targetWithNull, bindings).matches, + ).toBe(true); + expect( + evaluateCorrelationCondition(missing, targetWithNull, bindings).matches, + ).toBe(true); + }); + + it("rejects unknown aliases and item references outside arrays", () => { + const unknownAlias: CorrelationCondition = { + type: "comparison", + left: { kind: "target", source: "outputs", path: [] }, + operator: "=", + right: { kind: "activation", alias: "unknown" }, + }; + const misplacedItem: CorrelationCondition = { + type: "comparison", + left: { kind: "item", path: ["id"] }, + operator: "=", + right: { kind: "literal", value: 42 }, + }; + + expect(validateCorrelationCondition(unknownAlias, ["request_id"])).toEqual([ + "correlation.right references unknown activation variable $unknown.", + ]); + expect(validateCorrelationCondition(misplacedItem, [])).toEqual([ + "correlation.left uses an item reference outside contains-item.", + ]); + }); + + it("uses strict numeric comparisons without coercion", () => { + const condition: CorrelationCondition = { + type: "comparison", + left: { kind: "activation", alias: "request_amount" }, + operator: ">", + right: { kind: "literal", value: "80" }, + }; + + expect(evaluateCorrelationCondition(condition, target, bindings).matches).toBe( + false, + ); + }); +}); diff --git a/frontend/src/graph/transitionCorrelation.ts b/frontend/src/graph/transitionCorrelation.ts new file mode 100644 index 0000000..a69f6b6 --- /dev/null +++ b/frontend/src/graph/transitionCorrelation.ts @@ -0,0 +1,383 @@ +import { + resolveDataPath, + type ComparisonOperator, + type DataPathSegment, + type DataSource, + type JsonValue, + type TransitionData, +} from "./transitionConditions"; + +export type CaptureDefinition = { + alias: string; + source: DataSource; + path: DataPathSegment[]; +}; + +export type ActivationBindings = Record; + +export type CaptureResult = { + bindings: ActivationBindings; + errors: string[]; +}; + +export type CorrelationValueReference = + | { + kind: "literal"; + value: JsonValue; + } + | { + kind: "activation"; + alias: string; + } + | { + kind: "target"; + source: DataSource; + path: DataPathSegment[]; + } + | { + kind: "item"; + path: DataPathSegment[]; + }; + +export type CorrelationCondition = + | { + type: "comparison"; + left: CorrelationValueReference; + operator: Exclude; + right: CorrelationValueReference; + } + | { + type: "reference-exists"; + reference: CorrelationValueReference; + exists: boolean; + } + | { + type: "contains-item"; + source: DataSource; + path: DataPathSegment[]; + condition: CorrelationCondition; + } + | { + type: "group"; + operator: "and" | "or"; + conditions: CorrelationCondition[]; + }; + +export type CorrelationEvaluation = { + matches: boolean; + errors: string[]; +}; + +type ResolvedReference = { + found: boolean; + value: unknown; +}; + +const ALIAS_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isJsonValue(value: unknown): value is JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + + if (Array.isArray(value)) { + return value.every(isJsonValue); + } + + if (typeof value === "object") { + return Object.values(value as Record).every(isJsonValue); + } + + return false; +} + +function deepEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) { + return true; + } + + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => deepEqual(value, right[index])) + ); + } + + if ( + typeof left === "object" && + left !== null && + !Array.isArray(left) && + typeof right === "object" && + right !== null && + !Array.isArray(right) + ) { + const leftRecord = left as Record; + const rightRecord = right as Record; + const leftKeys = Object.keys(leftRecord); + const rightKeys = Object.keys(rightRecord); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(rightRecord, key) && + deepEqual(leftRecord[key], rightRecord[key]), + ) + ); + } + + return false; +} + +export function validateCaptureDefinitions( + definitions: readonly CaptureDefinition[], +): string[] { + const errors: string[] = []; + const aliases = new Set(); + + definitions.forEach((definition, index) => { + const location = `captures[${index}]`; + if (!ALIAS_PATTERN.test(definition.alias)) { + errors.push( + `${location}.alias must start with a letter or underscore and contain only letters, numbers, and underscores.`, + ); + } + if (aliases.has(definition.alias)) { + errors.push(`Duplicate capture alias: $${definition.alias}.`); + } + aliases.add(definition.alias); + }); + + return errors; +} + +export function captureActivationValues( + definitions: readonly CaptureDefinition[], + activation: TransitionData, +): CaptureResult { + const errors = validateCaptureDefinitions(definitions); + if (errors.length > 0) { + return { bindings: {}, errors }; + } + + const bindings: ActivationBindings = {}; + definitions.forEach((definition) => { + const resolved = resolveDataPath( + activation[definition.source], + definition.path, + ); + if (!resolved.found) { + errors.push(`Capture $${definition.alias} could not resolve its data path.`); + return; + } + if (!isJsonValue(resolved.value)) { + errors.push(`Capture $${definition.alias} did not resolve to JSON data.`); + return; + } + bindings[definition.alias] = structuredClone(resolved.value); + }); + + return { bindings: errors.length === 0 ? bindings : {}, errors }; +} + +function validateReference( + reference: CorrelationValueReference, + availableAliases: ReadonlySet, + insideItem: boolean, + location: string, +): string[] { + if (reference.kind === "activation" && !availableAliases.has(reference.alias)) { + return [`${location} references unknown activation variable $${reference.alias}.`]; + } + if (reference.kind === "item" && !insideItem) { + return [`${location} uses an item reference outside contains-item.`]; + } + return []; +} + +function validateCorrelationConditionAt( + condition: CorrelationCondition, + availableAliases: ReadonlySet, + insideItem: boolean, + location: string, +): string[] { + switch (condition.type) { + case "comparison": + return [ + ...validateReference( + condition.left, + availableAliases, + insideItem, + `${location}.left`, + ), + ...validateReference( + condition.right, + availableAliases, + insideItem, + `${location}.right`, + ), + ]; + case "reference-exists": + return validateReference( + condition.reference, + availableAliases, + insideItem, + `${location}.reference`, + ); + case "contains-item": + return validateCorrelationConditionAt( + condition.condition, + availableAliases, + true, + `${location}.condition`, + ); + case "group": + if (condition.conditions.length === 0) { + return [`${location} must contain at least one condition.`]; + } + return condition.conditions.flatMap((child, index) => + validateCorrelationConditionAt( + child, + availableAliases, + insideItem, + `${location}.conditions[${index}]`, + ), + ); + } +} + +export function validateCorrelationCondition( + condition: CorrelationCondition, + availableAliases: Iterable, +): string[] { + return validateCorrelationConditionAt( + condition, + new Set(availableAliases), + false, + "correlation", + ); +} + +function resolveReference( + reference: CorrelationValueReference, + target: TransitionData, + bindings: ActivationBindings, + item: unknown, +): ResolvedReference { + switch (reference.kind) { + case "literal": + return { found: true, value: reference.value }; + case "activation": + return Object.prototype.hasOwnProperty.call(bindings, reference.alias) + ? { found: true, value: bindings[reference.alias] } + : { found: false, value: undefined }; + case "target": + return resolveDataPath(target[reference.source], reference.path); + case "item": + return resolveDataPath(item, reference.path); + } +} + +function evaluateComparison( + operator: Extract["operator"], + left: ResolvedReference, + right: ResolvedReference, +): boolean { + if (!left.found || !right.found) { + return false; + } + + if (operator === "=") { + return deepEqual(left.value, right.value); + } + if (operator === "!=") { + return !deepEqual(left.value, right.value); + } + if (typeof left.value !== "number" || typeof right.value !== "number") { + return false; + } + + switch (operator) { + case "<": + return left.value < right.value; + case "<=": + return left.value <= right.value; + case ">": + return left.value > right.value; + case ">=": + return left.value >= right.value; + } +} + +function evaluateCorrelationConditionAt( + condition: CorrelationCondition, + target: TransitionData, + bindings: ActivationBindings, + item: unknown, +): boolean { + switch (condition.type) { + case "comparison": + return evaluateComparison( + condition.operator, + resolveReference(condition.left, target, bindings, item), + resolveReference(condition.right, target, bindings, item), + ); + case "reference-exists": { + const resolved = resolveReference( + condition.reference, + target, + bindings, + item, + ); + return condition.exists ? resolved.found : !resolved.found; + } + case "contains-item": { + const resolved = resolveDataPath(target[condition.source], condition.path); + return ( + resolved.found && + Array.isArray(resolved.value) && + resolved.value.some((candidate) => + evaluateCorrelationConditionAt( + condition.condition, + target, + bindings, + candidate, + ), + ) + ); + } + case "group": + return condition.operator === "and" + ? condition.conditions.every((child) => + evaluateCorrelationConditionAt(child, target, bindings, item), + ) + : condition.conditions.some((child) => + evaluateCorrelationConditionAt(child, target, bindings, item), + ); + } +} + +export function evaluateCorrelationCondition( + condition: CorrelationCondition, + target: TransitionData, + bindings: ActivationBindings, +): CorrelationEvaluation { + const errors = validateCorrelationCondition(condition, Object.keys(bindings)); + if (errors.length > 0) { + return { matches: false, errors }; + } + + return { + matches: evaluateCorrelationConditionAt( + condition, + target, + bindings, + undefined, + ), + errors: [], + }; +} diff --git a/frontend/src/graph/transitionDataCatalogue.test.ts b/frontend/src/graph/transitionDataCatalogue.test.ts new file mode 100644 index 0000000..3649fa6 --- /dev/null +++ b/frontend/src/graph/transitionDataCatalogue.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { + buildTransitionDataCatalogue, + formatTransitionDataPath, + getTransitionDataFields, +} from "./transitionDataCatalogue"; + +describe("formatTransitionDataPath", () => { + it("formats object and normalized array-item paths", () => { + expect( + formatTransitionDataPath("outputs", ["completed", "[]", "id"]), + ).toBe("outputs.completed[].id"); + }); +}); + +describe("buildTransitionDataCatalogue", () => { + it("discovers nested input and output fields", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "ProcessRequest", + inputs: { + request: { + id: 42, + priority: 5, + urgent: true, + }, + }, + outputs: { + accepted: true, + }, + }, + ]); + + expect(catalogue.allFields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayPath: "inputs.request.id", + valueTypes: ["number"], + occurrenceCount: 1, + }), + expect.objectContaining({ + displayPath: "inputs.request.urgent", + valueTypes: ["boolean"], + }), + expect.objectContaining({ + displayPath: "outputs.accepted", + valueTypes: ["boolean"], + }), + ]), + ); + }); + + it("normalizes and discovers fields inside array items", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "CompleteRequest", + outputs: { + completed: [ + { id: 42, status: "ok" }, + { id: 57, status: "queued" }, + ], + }, + }, + ]); + + expect(catalogue.allFields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayPath: "outputs.completed", + valueTypes: ["array"], + occurrenceCount: 1, + }), + expect.objectContaining({ + displayPath: "outputs.completed[]", + valueTypes: ["object"], + occurrenceCount: 2, + }), + expect.objectContaining({ + displayPath: "outputs.completed[].id", + valueTypes: ["number"], + occurrenceCount: 2, + }), + ]), + ); + }); + + it("deduplicates paths and records observed types and occurrences", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "Update", + inputs: { value: 1 }, + }, + { + transition: "Update", + inputs: { value: "one" }, + }, + { + transition: "Other", + inputs: { value: 2 }, + }, + ]); + + const field = catalogue.allFields.find( + (candidate) => candidate.displayPath === "inputs.value", + ); + + expect(field).toEqual({ + source: "inputs", + path: ["value"], + displayPath: "inputs.value", + valueTypes: ["number", "string"], + occurrenceCount: 3, + }); + }); + + it("keeps transition-specific catalogues separate", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "Submit", + inputs: { request: { id: 7 } }, + }, + { + transition: "Complete", + outputs: { result: { id: 7 } }, + }, + ]); + + expect( + getTransitionDataFields(catalogue, "Submit").map( + (field) => field.displayPath, + ), + ).toContain("inputs.request.id"); + expect( + getTransitionDataFields(catalogue, "Submit").map( + (field) => field.displayPath, + ), + ).not.toContain("outputs.result.id"); + expect( + getTransitionDataFields(catalogue, "Complete").map( + (field) => field.displayPath, + ), + ).toContain("outputs.result.id"); + }); + + it("falls back to all fields for a manually entered transition", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "Known", + inputs: { request: { id: 9 } }, + }, + ]); + + expect(getTransitionDataFields(catalogue, "Unknown")).toBe( + catalogue.allFields, + ); + }); + + it("sorts transition names and field paths deterministically", () => { + const catalogue = buildTransitionDataCatalogue([ + { + transition: "Zulu", + inputs: { z: 1, a: 2 }, + }, + { + transition: "Alpha", + outputs: { result: true }, + }, + ]); + + expect(catalogue.transitionNames).toEqual(["Alpha", "Zulu"]); + expect(catalogue.allFields.map((field) => field.displayPath)).toEqual( + [...catalogue.allFields.map((field) => field.displayPath)].sort(), + ); + }); + + it("does not retain complete sample values", () => { + const secret = "confidential-value"; + const catalogue = buildTransitionDataCatalogue([ + { + transition: "SecretTransition", + inputs: { secret }, + }, + ]); + + expect(JSON.stringify(catalogue)).not.toContain(secret); + }); + + it("does not mutate edge data", () => { + const edges = [ + { + transition: "Process", + inputs: { request: { id: 1 } }, + outputs: { completed: [{ id: 1 }] }, + }, + ]; + const original = structuredClone(edges); + + buildTransitionDataCatalogue(edges); + + expect(edges).toEqual(original); + }); +}); diff --git a/frontend/src/graph/transitionDataCatalogue.ts b/frontend/src/graph/transitionDataCatalogue.ts new file mode 100644 index 0000000..94745d3 --- /dev/null +++ b/frontend/src/graph/transitionDataCatalogue.ts @@ -0,0 +1,208 @@ +import type { + DataPathSegment, + DataSource, +} from "./transitionConditions"; + +export type TransitionDataValueType = + | "string" + | "number" + | "boolean" + | "null" + | "object" + | "array"; + +export type TransitionDataEdge = { + transition: string; + inputs?: unknown; + outputs?: unknown; +}; + +export type TransitionDataField = { + source: DataSource; + path: DataPathSegment[]; + displayPath: string; + valueTypes: TransitionDataValueType[]; + occurrenceCount: number; +}; + +export type TransitionDataCatalogue = { + transitionNames: string[]; + allFields: TransitionDataField[]; + fieldsByTransition: Record; +}; + +type MutableField = { + source: DataSource; + path: DataPathSegment[]; + displayPath: string; + valueTypes: Set; + occurrenceCount: number; +}; + +const ARRAY_ITEM_SEGMENT = "[]"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function valueTypeOf(value: unknown): TransitionDataValueType | null { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + + switch (typeof value) { + case "string": + return "string"; + case "number": + return Number.isFinite(value) ? "number" : null; + case "boolean": + return "boolean"; + case "object": + return "object"; + default: + return null; + } +} + +function fieldKey(source: DataSource, path: readonly DataPathSegment[]): string { + return `${source}:${JSON.stringify(path)}`; +} + +export function formatTransitionDataPath( + source: DataSource, + path: readonly DataPathSegment[], +): string { + if (path.length === 0) return source; + + let result = source; + + for (const segment of path) { + if (segment === ARRAY_ITEM_SEGMENT) { + result += ARRAY_ITEM_SEGMENT; + } else if (typeof segment === "number") { + result += `[${segment}]`; + } else { + result += `.${segment}`; + } + } + + return result; +} + +function recordField( + fields: Map, + source: DataSource, + path: DataPathSegment[], + value: unknown, +): void { + const valueType = valueTypeOf(value); + if (valueType === null) return; + + const key = fieldKey(source, path); + const existing = fields.get(key); + + if (existing) { + existing.valueTypes.add(valueType); + existing.occurrenceCount += 1; + return; + } + + fields.set(key, { + source, + path: [...path], + displayPath: formatTransitionDataPath(source, path), + valueTypes: new Set([valueType]), + occurrenceCount: 1, + }); +} + +function discoverValue( + fields: Map, + source: DataSource, + path: DataPathSegment[], + value: unknown, +): void { + recordField(fields, source, path, value); + + if (Array.isArray(value)) { + for (const item of value) { + discoverValue(fields, source, [...path, ARRAY_ITEM_SEGMENT], item); + } + return; + } + + if (isRecord(value)) { + for (const [key, child] of Object.entries(value)) { + discoverValue(fields, source, [...path, key], child); + } + } +} + +function immutableFields(fields: Map): TransitionDataField[] { + return [...fields.values()] + .map((field) => ({ + source: field.source, + path: [...field.path], + displayPath: field.displayPath, + valueTypes: [...field.valueTypes].sort(), + occurrenceCount: field.occurrenceCount, + })) + .sort( + (left, right) => + left.displayPath.localeCompare(right.displayPath) || + left.valueTypes.join(",").localeCompare(right.valueTypes.join(",")), + ); +} + +function discoverEdgeFields( + edge: TransitionDataEdge, + fields: Map, +): void { + if (edge.inputs !== undefined && edge.inputs !== null) { + discoverValue(fields, "inputs", [], edge.inputs); + } + + if (edge.outputs !== undefined && edge.outputs !== null) { + discoverValue(fields, "outputs", [], edge.outputs); + } +} + +/** + * Discovers structured input and output paths without retaining sample values. + * Array indexes are normalized to [] so equivalent token fields are combined. + */ +export function buildTransitionDataCatalogue( + edges: readonly TransitionDataEdge[], +): TransitionDataCatalogue { + const allFields = new Map(); + const fieldsByTransition = new Map>(); + const transitionNames = new Set(); + + for (const edge of edges) { + transitionNames.add(edge.transition); + discoverEdgeFields(edge, allFields); + + const transitionFields = + fieldsByTransition.get(edge.transition) ?? new Map(); + discoverEdgeFields(edge, transitionFields); + fieldsByTransition.set(edge.transition, transitionFields); + } + + return { + transitionNames: [...transitionNames].sort((left, right) => + left.localeCompare(right), + ), + allFields: immutableFields(allFields), + fieldsByTransition: Object.fromEntries( + [...fieldsByTransition.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([transition, fields]) => [transition, immutableFields(fields)]), + ), + }; +} + +export function getTransitionDataFields( + catalogue: TransitionDataCatalogue, + transitionName: string, +): readonly TransitionDataField[] { + return catalogue.fieldsByTransition[transitionName] ?? catalogue.allFields; +} diff --git a/frontend/src/ui/sidePanelState.test.ts b/frontend/src/ui/sidePanelState.test.ts new file mode 100644 index 0000000..32bc48d --- /dev/null +++ b/frontend/src/ui/sidePanelState.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SIDE_PANEL_WIDTH, + MAX_SIDE_PANEL_WIDTH, + MIN_SIDE_PANEL_WIDTH, + clampSidePanelWidth, + getKeyboardResizedSidePanelWidth, + getSidePanelMaximumWidth, + parseStoredSidePanelCollapsed, + parseStoredSidePanelWidth, +} from "./sidePanelState"; + +describe("side panel state", () => { + it("uses the configured maximum on a wide viewport", () => { + expect(getSidePanelMaximumWidth(1920)).toBe(MAX_SIDE_PANEL_WIDTH); + }); + + it("uses a viewport-aware maximum", () => { + expect(getSidePanelMaximumWidth(1000)).toBe(550); + }); + + it("never allows the viewport maximum below the minimum", () => { + expect(getSidePanelMaximumWidth(500)).toBe(MIN_SIDE_PANEL_WIDTH); + }); + + it("clamps widths to both boundaries", () => { + expect(clampSidePanelWidth(100, 1920)).toBe(MIN_SIDE_PANEL_WIDTH); + expect(clampSidePanelWidth(900, 1920)).toBe(MAX_SIDE_PANEL_WIDTH); + expect(clampSidePanelWidth(520, 1920)).toBe(520); + }); + + it("restores and clamps a stored width", () => { + expect(parseStoredSidePanelWidth("640", 1920)).toBe(640); + expect(parseStoredSidePanelWidth("900", 1000)).toBe(550); + }); + + it("falls back safely for missing or invalid stored widths", () => { + expect(parseStoredSidePanelWidth(null, 1920)).toBe(DEFAULT_SIDE_PANEL_WIDTH); + expect(parseStoredSidePanelWidth("", 1920)).toBe(DEFAULT_SIDE_PANEL_WIDTH); + expect(parseStoredSidePanelWidth("not-a-number", 1920)).toBe( + DEFAULT_SIDE_PANEL_WIDTH, + ); + }); + + it("only restores the explicit collapsed value", () => { + expect(parseStoredSidePanelCollapsed("true")).toBe(true); + expect(parseStoredSidePanelCollapsed("false")).toBe(false); + expect(parseStoredSidePanelCollapsed(null)).toBe(false); + expect(parseStoredSidePanelCollapsed("TRUE")).toBe(false); + }); + + it("supports normal and accelerated keyboard resizing", () => { + expect(getKeyboardResizedSidePanelWidth(480, "ArrowLeft", false, 1920)).toBe(496); + expect(getKeyboardResizedSidePanelWidth(480, "ArrowRight", false, 1920)).toBe(464); + expect(getKeyboardResizedSidePanelWidth(480, "ArrowLeft", true, 1920)).toBe(530); + expect(getKeyboardResizedSidePanelWidth(480, "ArrowRight", true, 1920)).toBe(430); + }); + + it("supports Home and viewport-aware End", () => { + expect(getKeyboardResizedSidePanelWidth(480, "Home", false, 1000)).toBe(380); + expect(getKeyboardResizedSidePanelWidth(480, "End", false, 1000)).toBe(550); + }); + + it("ignores unrelated keys", () => { + expect(getKeyboardResizedSidePanelWidth(480, "Enter", false, 1920)).toBeNull(); + }); +}); diff --git a/frontend/src/ui/sidePanelState.ts b/frontend/src/ui/sidePanelState.ts new file mode 100644 index 0000000..7cc6b93 --- /dev/null +++ b/frontend/src/ui/sidePanelState.ts @@ -0,0 +1,70 @@ +export const MIN_SIDE_PANEL_WIDTH = 380; +export const DEFAULT_SIDE_PANEL_WIDTH = 480; +export const MAX_SIDE_PANEL_WIDTH = 760; +export const SIDE_PANEL_WIDTH_STORAGE_KEY = "ltsvisualizer.sidePanelWidth"; +export const SIDE_PANEL_COLLAPSED_STORAGE_KEY = "ltsvisualizer.sidePanelCollapsed"; + +export function getSidePanelMaximumWidth(viewportWidth: number): number { + const safeViewportWidth = Number.isFinite(viewportWidth) + ? Math.max(0, viewportWidth) + : 0; + return Math.max( + MIN_SIDE_PANEL_WIDTH, + Math.min(MAX_SIDE_PANEL_WIDTH, safeViewportWidth * 0.55), + ); +} + +export function clampSidePanelWidth( + width: number, + viewportWidth: number, +): number { + const maximum = getSidePanelMaximumWidth(viewportWidth); + const safeWidth = Number.isFinite(width) ? width : DEFAULT_SIDE_PANEL_WIDTH; + return Math.min(maximum, Math.max(MIN_SIDE_PANEL_WIDTH, safeWidth)); +} + +export function parseStoredSidePanelWidth( + stored: string | null, + viewportWidth: number, +): number { + if (stored === null || stored.trim() === "") { + return clampSidePanelWidth(DEFAULT_SIDE_PANEL_WIDTH, viewportWidth); + } + const parsed = Number(stored); + return Number.isFinite(parsed) + ? clampSidePanelWidth(parsed, viewportWidth) + : clampSidePanelWidth(DEFAULT_SIDE_PANEL_WIDTH, viewportWidth); +} + +export function parseStoredSidePanelCollapsed(stored: string | null): boolean { + return stored === "true"; +} + +export function getKeyboardResizedSidePanelWidth( + currentWidth: number, + key: string, + shiftKey: boolean, + viewportWidth: number, +): number | null { + const increment = shiftKey ? 50 : 16; + let requestedWidth: number; + + switch (key) { + case "ArrowLeft": + requestedWidth = currentWidth + increment; + break; + case "ArrowRight": + requestedWidth = currentWidth - increment; + break; + case "Home": + requestedWidth = MIN_SIDE_PANEL_WIDTH; + break; + case "End": + requestedWidth = getSidePanelMaximumWidth(viewportWidth); + break; + default: + return null; + } + + return clampSidePanelWidth(requestedWidth, viewportWidth); +}