From 636f0d023fe8683160428fefe1ac713ebb550c0a Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 29 Aug 2026 04:08:00 +0200 Subject: [PATCH] FE-1509: Add the notebook view's data model and layout foundations Pure, unit-tested projections of an SDCPN for the notebook view built on top of them: the cell list and its dependency-edge index covering all five entity kinds, topological cell ordering with declarations inlined before their first user, and a cut-down Sugiyama layered layout with a focus mode that re-layers by hop distance. --- .../petrinaut/src/ui/views/Notebook/README.md | 23 + .../views/Notebook/net-graph-layout.test.ts | 232 ++++++++ .../src/ui/views/Notebook/net-graph-layout.ts | 444 +++++++++++++++ .../ui/views/Notebook/notebook-model.test.ts | 401 +++++++++++++ .../src/ui/views/Notebook/notebook-model.ts | 535 ++++++++++++++++++ .../ui/views/Notebook/notebook-order.test.ts | 171 ++++++ .../src/ui/views/Notebook/notebook-order.ts | 83 +++ 7 files changed, 1889 insertions(+) create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts create mode 100644 libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md new file mode 100644 index 00000000000..8c1e166df4d --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md @@ -0,0 +1,23 @@ +--- +layer: ui.views.notebook +role: Notebook view — the net as expandable cells with editable code, dependency analysis, and a whole-net graph explorer +--- + +The notebook renders the net as a flat list of cells, one per entity, so a +model reads like a program: declarations and the flow that uses them. It replaces the canvas and its +panels wholesale, which is what lets its Monaco editors reuse the LSP +document URIs — a model is never mounted twice. + +Everything an expanded cell shows edits in place — names, fields, arc +weights, type assignments, and code — through the same guarded mutations as +the properties panel; only adding and removing nodes, arcs, and fields +stays in Edit mode. + +The folder splits into a pure core and thin views. `notebook-model`, +`notebook-order`, `net-cycles`, `net-siphons` and `net-graph-layout` are +plain functions over the net definition, unit-tested without the DOM; the +`.tsx` files render their output and own only view state (selection comes +from the editor, expansion and search live here). The graph explorer draws +the whole net from the arc structure alone, ignoring canvas positions, so +the diagram answers "what feeds what" rather than "where did the author +drag things". diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts new file mode 100644 index 00000000000..824ba6db385 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "vitest"; + +import { edgePath, layoutNetGraph, NET_NODE_HEIGHT } from "./net-graph-layout"; + +import type { NetGraph, NetGraphNode } from "./notebook-model"; + +const place = (id: string): NetGraphNode => ({ id, name: id, kind: "place" }); +const transition = (id: string): NetGraphNode => ({ + id, + name: id, + kind: "transition", +}); + +const layerOf = (graph: NetGraph, id: string): number => { + const node = layoutNetGraph(graph).nodes.find((entry) => entry.id === id); + if (node === undefined) { + throw new Error(`${id} should be laid out`); + } + return node.layer; +}; + +describe("layoutNetGraph", () => { + it("returns an empty layout for an empty graph", () => { + expect(layoutNetGraph({ nodes: [], edges: [] })).toEqual({ + width: 0, + height: 0, + nodes: [], + edges: [], + }); + }); + + it("stacks a chain into one node per layer, in flow order", () => { + const graph: NetGraph = { + nodes: [place("Source"), transition("Move"), place("Sink")], + edges: [ + { from: "Source", to: "Move" }, + { from: "Move", to: "Sink" }, + ], + }; + + expect(layerOf(graph, "Source")).toBe(0); + expect(layerOf(graph, "Move")).toBe(1); + expect(layerOf(graph, "Sink")).toBe(2); + + const layout = layoutNetGraph(graph); + expect(layout.edges.every(({ isBackEdge }) => !isBackEdge)).toBe(true); + expect(layout.height).toBeGreaterThan(NET_NODE_HEIGHT * 3); + }); + + it("puts a node after its deepest dependency, not its shallowest", () => { + // Direct : Source -> Join + // Indirect: Source -> Detour -> Join + const graph: NetGraph = { + nodes: [place("Source"), transition("Detour"), transition("Join")], + edges: [ + { from: "Source", to: "Join" }, + { from: "Source", to: "Detour" }, + { from: "Detour", to: "Join" }, + ], + }; + + expect(layerOf(graph, "Detour")).toBe(1); + expect(layerOf(graph, "Join")).toBe(2); + }); + + it("breaks a cycle so every node still gets a layer", () => { + const graph: NetGraph = { + nodes: [place("Pool"), transition("Churn")], + edges: [ + { from: "Pool", to: "Churn" }, + { from: "Churn", to: "Pool" }, + ], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes).toHaveLength(2); + expect( + layout.nodes.map(({ layer }) => layer).sort((a, b) => a - b), + ).toEqual([0, 1]); + // Exactly one of the two arcs closes the cycle and is drawn as a return. + expect(layout.edges.filter(({ isBackEdge }) => isBackEdge)).toHaveLength(1); + }); + + it("lays out a graph with no roots at all", () => { + const graph: NetGraph = { + nodes: [place("A"), transition("B"), place("C")], + edges: [ + { from: "A", to: "B" }, + { from: "B", to: "C" }, + { from: "C", to: "A" }, + ], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes).toHaveLength(3); + expect(layout.edges.filter(({ isBackEdge }) => isBackEdge)).toHaveLength(1); + }); + + it("is deterministic for the same input", () => { + const graph: NetGraph = { + nodes: [place("P1"), place("P2"), transition("T"), place("P3")], + edges: [ + { from: "P1", to: "T" }, + { from: "P2", to: "T" }, + { from: "T", to: "P3" }, + ], + }; + + expect(layoutNetGraph(graph)).toEqual(layoutNetGraph(graph)); + }); + + it("keeps disconnected nodes in the graph", () => { + const graph: NetGraph = { + nodes: [place("Lonely"), place("Source"), transition("Move")], + edges: [{ from: "Source", to: "Move" }], + }; + + const layout = layoutNetGraph(graph); + + expect(layout.nodes.map(({ id }) => id).sort()).toEqual([ + "Lonely", + "Move", + "Source", + ]); + expect(layerOf(graph, "Lonely")).toBe(0); + }); +}); + +describe("layoutNetGraph with a focus node", () => { + const chain: NetGraph = { + nodes: [ + place("Source"), + transition("Move"), + place("Middle"), + transition("Ship"), + place("Sink"), + place("Detached"), + ], + edges: [ + { from: "Source", to: "Move" }, + { from: "Move", to: "Middle" }, + { from: "Middle", to: "Ship" }, + { from: "Ship", to: "Sink" }, + ], + }; + + const layerFor = (focusId: string, id: string): number => { + const node = layoutNetGraph(chain, { focusId }).nodes.find( + (entry) => entry.id === id, + ); + if (node === undefined) { + throw new Error(`${id} should be laid out`); + } + return node.layer; + }; + + it("stacks dependencies above and dependents below the focus", () => { + const focus = layerFor("Middle", "Middle"); + + expect(layerFor("Middle", "Move")).toBe(focus - 1); + expect(layerFor("Middle", "Source")).toBe(focus - 2); + expect(layerFor("Middle", "Ship")).toBe(focus + 1); + expect(layerFor("Middle", "Sink")).toBe(focus + 2); + }); + + it("keeps unreachable nodes in the layout, below everything else", () => { + const layout = layoutNetGraph(chain, { focusId: "Middle" }); + + expect(layout.nodes).toHaveLength(chain.nodes.length); + expect(layerFor("Middle", "Detached")).toBeGreaterThan( + layerFor("Middle", "Sink"), + ); + }); + + it("ignores a focus id that is not in the graph", () => { + expect(layoutNetGraph(chain, { focusId: "nope" })).toEqual( + layoutNetGraph(chain), + ); + }); + + it("layers an unreachable component instead of collapsing it to one row", () => { + const graph: NetGraph = { + nodes: [ + place("Focus"), + transition("Feed"), + // A separate flow the focus can't reach either way. + place("OtherSource"), + transition("OtherMove"), + place("OtherSink"), + ], + edges: [ + { from: "Focus", to: "Feed" }, + { from: "OtherSource", to: "OtherMove" }, + { from: "OtherMove", to: "OtherSink" }, + ], + }; + const layout = layoutNetGraph(graph, { focusId: "Focus" }); + const layer = (id: string) => + layout.nodes.find((entry) => entry.id === id)!.layer; + + // The component keeps its own flow order below the reachable band… + expect(layer("OtherSource")).toBeGreaterThan(layer("Feed")); + expect(layer("OtherMove")).toBe(layer("OtherSource") + 1); + expect(layer("OtherSink")).toBe(layer("OtherMove") + 1); + // …so its edges still travel downwards rather than being classified as + // degenerate same-row returns. + for (const edge of layout.edges) { + expect(edge.isBackEdge).toBe(false); + } + }); +}); + +describe("edgePath", () => { + it("keeps a same-row return edge visible by bowing below the row", () => { + const path = edgePath({ x: 0, y: 50 }, { x: 200, y: 50 }, true); + // A flat bow would put every coordinate on y = centre; the dip moves the + // control points off the row so the curve has visible area. + const centreY = 50 + NET_NODE_HEIGHT / 2; + expect(path).toContain(`${centreY + NET_NODE_HEIGHT}`); + }); + + it("does not dip a return edge that spans rows", () => { + const from = { x: 0, y: 100 }; + const to = { x: 0, y: 0 }; + const path = edgePath(from, to, true); + const fromCentreY = 100 + NET_NODE_HEIGHT / 2; + const toCentreY = NET_NODE_HEIGHT / 2; + expect(path).toContain(`C 104 ${fromCentreY}, 104 ${toCentreY}`); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts new file mode 100644 index 00000000000..949b1c962cb --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/net-graph-layout.ts @@ -0,0 +1,444 @@ +/** + * Layered layout for the whole-net graph, computed from the arc structure + * alone — the net's stored x/y positions are deliberately ignored. + * + * A cut-down Sugiyama pipeline: break cycles with a depth-first sweep, assign + * layers by longest path, reduce crossings with barycentre ordering, then + * place coordinates. + */ + +import type { NetGraph, NetGraphEdge, NetGraphNode } from "./notebook-model"; + +export const NET_NODE_WIDTH = 86; +export const NET_NODE_HEIGHT = 22; + +const PADDING = 10; +const COLUMN_GAP = 10; +const ROW_GAP = 34; +/** How far a return edge bows clear of the nodes it connects. */ +const BACK_EDGE_BOW = 18; +/** Barycentre sweeps; two is plenty for nets of this size. */ +const ORDERING_PASSES = 2; + +export type PositionedNetNode = NetGraphNode & { + x: number; + y: number; + layer: number; +}; + +export type LaidOutEdge = NetGraphEdge & { + key: string; + /** + * True when the edge does not travel downwards through the layers; drawn + * as a bowed return path instead of a straight drop. + */ + isBackEdge: boolean; +}; + +export type NetGraphLayout = { + width: number; + height: number; + nodes: PositionedNetNode[]; + edges: LaidOutEdge[]; +}; + +const edgeKey = (edge: NetGraphEdge) => `${edge.from} ${edge.to}`; + +const groupTargets = (edges: NetGraphEdge[]): Map => { + const byFrom = new Map(); + for (const edge of edges) { + const existing = byFrom.get(edge.from); + if (existing === undefined) { + byFrom.set(edge.from, [edge.to]); + } else { + existing.push(edge.to); + } + } + return byFrom; +}; + +const groupSources = (edges: NetGraphEdge[]): Map => { + const byTo = new Map(); + for (const edge of edges) { + const existing = byTo.get(edge.to); + if (existing === undefined) { + byTo.set(edge.to, [edge.from]); + } else { + existing.push(edge.from); + } + } + return byTo; +}; + +/** + * Find the edges that close a cycle, using an iterative depth-first search. + * Removing them leaves a DAG, which is what the layering step needs. + */ +function findBackEdges(graph: NetGraph): Set { + const targetsByNode = groupTargets(graph.edges); + const backEdges = new Set(); + /** 0 = unvisited, 1 = on the current path, 2 = finished. */ + const state = new Map(); + + for (const root of graph.nodes) { + if ((state.get(root.id) ?? 0) !== 0) { + continue; + } + state.set(root.id, 1); + const stack: { id: string; nextTarget: number }[] = [ + { id: root.id, nextTarget: 0 }, + ]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]!; + const targets = targetsByNode.get(frame.id) ?? []; + + if (frame.nextTarget >= targets.length) { + state.set(frame.id, 2); + stack.pop(); + continue; + } + + const target = targets[frame.nextTarget]!; + frame.nextTarget += 1; + const targetState = state.get(target) ?? 0; + + if (targetState === 1) { + backEdges.add(edgeKey({ from: frame.id, to: target })); + } else if (targetState === 0) { + state.set(target, 1); + stack.push({ id: target, nextTarget: 0 }); + } + } + } + + return backEdges; +} + +/** Longest-path layering over the acyclic edge set. */ +function assignLayers( + nodes: NetGraphNode[], + dagEdges: NetGraphEdge[], +): Map { + const layer = new Map(nodes.map(({ id }) => [id, 0])); + const remainingInDegree = new Map( + nodes.map(({ id }) => [id, 0]), + ); + for (const edge of dagEdges) { + remainingInDegree.set(edge.to, (remainingInDegree.get(edge.to) ?? 0) + 1); + } + + const targetsByNode = groupTargets(dagEdges); + const queue = nodes + .filter(({ id }) => remainingInDegree.get(id) === 0) + .map(({ id }) => id); + + for (let head = 0; head < queue.length; head++) { + const id = queue[head]!; + for (const target of targetsByNode.get(id) ?? []) { + layer.set(target, Math.max(layer.get(target)!, layer.get(id)! + 1)); + const remaining = (remainingInDegree.get(target) ?? 0) - 1; + remainingInDegree.set(target, remaining); + if (remaining === 0) { + queue.push(target); + } + } + } + + return layer; +} + +/** Mean position of a node's neighbours in the adjacent layer. */ +const barycentre = ( + neighbours: string[], + positions: Map, + fallback: number, +): number => { + const known = neighbours + .map((id) => positions.get(id)) + .filter((position): position is number => position !== undefined); + return known.length === 0 + ? fallback + : known.reduce((total, position) => total + position, 0) / known.length; +}; + +const positionsOf = (layerIds: string[]): Map => + new Map(layerIds.map((id, index) => [id, index])); + +/** + * Layer nodes by hop distance from one node: the focus sits in the middle, + * everything upstream of it above (negative distance), everything downstream + * below. Nodes the focus can't reach either way keep the graph's context by + * being parked in a band underneath. + * + * A node reachable both ways — a cycle through the focus — takes whichever + * side is nearer, and the cycle badge and return edge carry the loop. + */ +function assignFocusLayers( + graph: NetGraph, + focusId: string, + dagEdges: NetGraphEdge[], +): Map { + const targetsByNode = groupTargets(graph.edges); + const sourcesByNode = groupSources(graph.edges); + + const walk = ( + adjacency: Map, + sign: 1 | -1, + ): Map => { + const distances = new Map(); + let frontier = [focusId]; + let distance = 0; + const seen = new Set([focusId]); + + while (frontier.length > 0) { + distance += 1; + const next: string[] = []; + for (const id of frontier) { + for (const neighbour of adjacency.get(id) ?? []) { + if (seen.has(neighbour)) { + continue; + } + seen.add(neighbour); + distances.set(neighbour, sign * distance); + next.push(neighbour); + } + } + frontier = next; + } + + return distances; + }; + + const downstream = walk(targetsByNode, 1); + const upstream = walk(sourcesByNode, -1); + + const distances = new Map([[focusId, 0]]); + for (const node of graph.nodes) { + if (node.id === focusId) { + continue; + } + const below = downstream.get(node.id); + const above = upstream.get(node.id); + if (below !== undefined && above !== undefined) { + distances.set(node.id, Math.abs(above) <= below ? above : below); + } else if (below !== undefined) { + distances.set(node.id, below); + } else if (above !== undefined) { + distances.set(node.id, above); + } + } + + // Unreachable nodes sit below everything else rather than vanishing. They + // keep their own longest-path layering — collapsing them into a single band + // would put whole disconnected components on one row, leaving their edges + // as degenerate same-row curves. + const unreachable = graph.nodes.filter((node) => !distances.has(node.id)); + if (unreachable.length > 0) { + const unreachableIds = new Set(unreachable.map(({ id }) => id)); + const unreachableEdges = dagEdges.filter( + (edge) => unreachableIds.has(edge.from) && unreachableIds.has(edge.to), + ); + const subLayers = assignLayers(unreachable, unreachableEdges); + const deepest = Math.max(0, ...distances.values()); + for (const [id, subLayer] of subLayers) { + distances.set(id, deepest + 2 + subLayer); + } + } + + // Collapse the signed distances to dense layer indices. + const usedDistances = [...new Set(distances.values())].sort( + (left, right) => left - right, + ); + const layerByDistance = new Map( + usedDistances.map((value, index) => [value, index]), + ); + + return new Map( + [...distances].map(([id, value]) => [id, layerByDistance.get(value)!]), + ); +} + +/** + * Order nodes within each layer to reduce edge crossings, sweeping down then + * up. `Array.prototype.sort` is stable, so equal barycentres keep their + * relative order and the result is deterministic. + */ +function orderLayers( + initialLayers: string[][], + dagEdges: NetGraphEdge[], +): string[][] { + const sourcesByNode = groupSources(dagEdges); + const targetsByNode = groupTargets(dagEdges); + + const sortByAdjacent = ( + layers: string[][], + index: number, + adjacentLayer: string[], + neighboursByNode: Map, + ): string[] => { + const adjacentPositions = positionsOf(adjacentLayer); + const current = layers[index]!; + const scores = new Map( + current.map((id, position) => [ + id, + barycentre(neighboursByNode.get(id) ?? [], adjacentPositions, position), + ]), + ); + return [...current].sort( + (left, right) => scores.get(left)! - scores.get(right)!, + ); + }; + + let layers = initialLayers.map((ids) => [...ids]); + + for (let pass = 0; pass < ORDERING_PASSES; pass++) { + const downward = layers.map((ids) => [...ids]); + for (let index = 1; index < downward.length; index++) { + downward[index] = sortByAdjacent( + downward, + index, + downward[index - 1]!, + sourcesByNode, + ); + } + + const upward = downward.map((ids) => [...ids]); + for (let index = upward.length - 2; index >= 0; index--) { + upward[index] = sortByAdjacent( + upward, + index, + upward[index + 1]!, + targetsByNode, + ); + } + + layers = upward; + } + + return layers; +} + +export interface LayoutOptions { + /** + * Re-layer around this node instead of by longest path: it takes the middle + * row, its dependencies stack above and its dependents below. + */ + focusId?: string | null; +} + +export function layoutNetGraph( + graph: NetGraph, + options: LayoutOptions = {}, +): NetGraphLayout { + if (graph.nodes.length === 0) { + return { width: 0, height: 0, nodes: [], edges: [] }; + } + + const backEdges = findBackEdges(graph); + const dagEdges = graph.edges.filter((edge) => !backEdges.has(edgeKey(edge))); + + const focusId = + options.focusId != null && + graph.nodes.some((node) => node.id === options.focusId) + ? options.focusId + : null; + + const layerByNode = + focusId === null + ? assignLayers(graph.nodes, dagEdges) + : assignFocusLayers(graph, focusId, dagEdges); + + const layers: string[][] = []; + for (const node of graph.nodes) { + const index = layerByNode.get(node.id)!; + while (layers.length <= index) { + layers.push([]); + } + layers[index]!.push(node.id); + } + + const orderedLayers = orderLayers(layers, dagEdges); + + const layerWidths = orderedLayers.map( + (ids) => ids.length * NET_NODE_WIDTH + (ids.length - 1) * COLUMN_GAP, + ); + const contentWidth = Math.max(...layerWidths); + // Return edges bow out past the rightmost node, so they get their own lane; + // without it the SVG viewport clips the curve's peak. + const hasBackEdge = graph.edges.some( + (edge) => + (layerByNode.get(edge.to) ?? 0) <= (layerByNode.get(edge.from) ?? 0), + ); + const width = contentWidth + PADDING * 2 + (hasBackEdge ? BACK_EDGE_BOW : 0); + const height = + PADDING * 2 + + orderedLayers.length * NET_NODE_HEIGHT + + (orderedLayers.length - 1) * ROW_GAP; + + const nodesById = new Map(graph.nodes.map((node) => [node.id, node])); + const nodes: PositionedNetNode[] = []; + + orderedLayers.forEach((ids, layer) => { + const startX = PADDING + (contentWidth - layerWidths[layer]!) / 2; + ids.forEach((id, position) => { + const node = nodesById.get(id)!; + nodes.push({ + ...node, + layer, + x: startX + position * (NET_NODE_WIDTH + COLUMN_GAP), + y: PADDING + layer * (NET_NODE_HEIGHT + ROW_GAP), + }); + }); + }); + + // Classified by geometry rather than by the layering pass, so an edge is + // drawn as a return path exactly when it doesn't travel downwards — true in + // both the default and the focused layering. + const edges: LaidOutEdge[] = graph.edges.map((edge) => ({ + ...edge, + key: edgeKey(edge), + isBackEdge: + (layerByNode.get(edge.to) ?? 0) <= (layerByNode.get(edge.from) ?? 0), + })); + + return { width, height, nodes, edges }; +} + +/** A node's top-left corner, which is all the edge geometry needs. */ +export type Point = { x: number; y: number }; + +const anchorsOf = (point: Point) => ({ + centreX: point.x + NET_NODE_WIDTH / 2, + centreY: point.y + NET_NODE_HEIGHT / 2, + top: point.y, + bottom: point.y + NET_NODE_HEIGHT, + right: point.x + NET_NODE_WIDTH, +}); + +/** + * Forward edges drop from one layer to the next; a cycle-closing edge bows out + * to the right of both endpoints so it never runs through the layers. + * + * Takes plain points rather than laid-out nodes so the animation can call it + * with interpolated positions mid-flight. + */ +export function edgePath(from: Point, to: Point, isBackEdge: boolean): string { + const source = anchorsOf(from); + const target = anchorsOf(to); + + if (isBackEdge) { + const bowX = Math.max(source.right, target.right) + BACK_EDGE_BOW; + // A same-row return (both ends focused to the same layer) has no vertical + // span to curve through, so dip the control points below the row — a flat + // bow would degenerate into an invisible horizontal line. + const dip = + Math.abs(source.centreY - target.centreY) < NET_NODE_HEIGHT + ? NET_NODE_HEIGHT + : 0; + return `M ${source.right} ${source.centreY} C ${bowX} ${source.centreY + dip}, ${bowX} ${target.centreY + dip}, ${target.right} ${target.centreY}`; + } + + const midY = (source.bottom + target.top) / 2; + return `M ${source.centreX} ${source.bottom} C ${source.centreX} ${midY}, ${target.centreX} ${midY}, ${target.centreX} ${target.top}`; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts new file mode 100644 index 00000000000..1320bfd93ad --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.test.ts @@ -0,0 +1,401 @@ +import { describe, expect, it } from "vitest"; + +import { + buildConnectionIndex, + buildDependentCounts, + buildNetGraph, + buildNodeNeighbourhood, + fuzzyMatchName, +} from "./notebook-model"; + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { NodeRef } from "./notebook-model"; + +const emptyNet: ActiveNetDefinition = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + componentInstances: [], +}; + +const place = ( + id: string, + overrides: Partial = {}, +): ActiveNetDefinition["places"][number] => ({ + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + ...overrides, +}); + +const transition = ( + id: string, + overrides: Partial = {}, +): ActiveNetDefinition["transitions"][number] => ({ + id, + name: id, + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + ...overrides, +}); + +const parameter = ( + id: string, + variableName: string, +): ActiveNetDefinition["parameters"][number] => ({ + id, + name: id, + variableName, + type: "real", + defaultValue: "1", +}); + +const names = (refs: { name: string }[]) => refs.map(({ name }) => name).sort(); + +describe("buildConnectionIndex", () => { + it("links a transition to its input places upstream and output places downstream", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Sink")], + transitions: [ + transition("Move", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Move")!.upstream)).toEqual(["Source"]); + expect(names(index.get("Move")!.downstream)).toEqual(["Sink"]); + // The reverse direction is derived from the same edges. + expect(names(index.get("Source")!.downstream)).toEqual(["Move"]); + expect(names(index.get("Sink")!.upstream)).toEqual(["Move"]); + }); + + it("links a place to its token type and differential equation", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [ + place("Stock", { + colorId: "colour", + dynamicsEnabled: true, + differentialEquationId: "decay", + }), + ], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: "colour", code: "" }, + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Stock")!.upstream)).toEqual(["Decay", "Widget"]); + expect(names(index.get("colour")!.downstream)).toEqual(["Decay", "Stock"]); + expect(names(index.get("decay")!.upstream)).toEqual(["Widget"]); + expect(names(index.get("decay")!.downstream)).toEqual(["Stock"]); + }); + + it("links parameters to the transitions and equations whose code reads them", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + parameters: [parameter("rate", "growth_rate")], + transitions: [ + transition("Grow", { lambdaCode: "return parameters.growth_rate;" }), + transition("Idle", { lambdaCode: "return 1;" }), + ], + differentialEquations: [ + { + id: "growth", + name: "Growth", + colorId: null, + code: "return growth_rate * x;", + }, + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("rate")!.downstream)).toEqual(["Grow", "Growth"]); + expect(names(index.get("Grow")!.upstream)).toEqual(["rate"]); + expect(index.get("Idle")).toBeUndefined(); + }); + + it("only counts parameter references bounded by non-identifier characters", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + parameters: [parameter("rate", "growth_rate")], + transitions: [ + transition("Longer", { lambdaCode: "return growth_rate2;" }), + transition("Prefixed", { lambdaCode: "return my_growth_rate;" }), + transition("Dollar", { lambdaCode: "return $growth_rate;" }), + transition("Exact", { + lambdaCode: "return growth_rate2 + growth_rate;", + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("rate")!.downstream)).toEqual(["Exact"]); + }); + + it("records both directions when a place is an input and an output of one transition", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Pool")], + transitions: [ + transition("Churn", { + inputArcs: [{ placeId: "Pool", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Pool", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(names(index.get("Churn")!.upstream)).toEqual(["Pool"]); + expect(names(index.get("Churn")!.downstream)).toEqual(["Pool"]); + }); + + it("ignores component port arc endpoints", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Local")], + transitions: [ + transition("Bridge", { + inputArcs: [ + { + endpoint: { + kind: "componentPort", + componentInstanceId: "instance", + portPlaceId: "port", + }, + weight: 1, + type: "standard", + }, + ], + outputArcs: [{ placeId: "Local", weight: 1 }], + }), + ], + }; + + const index = buildConnectionIndex(net); + + expect(index.get("Bridge")!.upstream).toEqual([]); + expect(names(index.get("Bridge")!.downstream)).toEqual(["Local"]); + }); +}); + +describe("buildNodeNeighbourhood", () => { + const ref = ( + type: "place" | "transition" | "parameter", + id: string, + ): NodeRef => ({ type, id, name: id }); + + it("keeps only places and transitions", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Source"), ref("parameter", "rate")], + downstream: [ref("transition", "Move")], + }); + + expect(names(neighbourhood.dependencies)).toEqual(["Source"]); + expect(names(neighbourhood.dependents)).toEqual(["Move"]); + expect(neighbourhood.bidirectional).toEqual([]); + }); + + it("moves a neighbour reachable both ways into the bidirectional bucket", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Pool"), ref("place", "Source")], + downstream: [ref("place", "Pool"), ref("place", "Sink")], + }); + + expect(names(neighbourhood.bidirectional)).toEqual(["Pool"]); + expect(names(neighbourhood.dependencies)).toEqual(["Source"]); + expect(names(neighbourhood.dependents)).toEqual(["Sink"]); + }); + + it("deduplicates repeated refs", () => { + const neighbourhood = buildNodeNeighbourhood({ + upstream: [ref("place", "Source"), ref("place", "Source")], + downstream: [], + }); + + expect(neighbourhood.dependencies).toHaveLength(1); + }); +}); + +describe("buildNetGraph", () => { + it("turns input arcs into place -> transition and output arcs into transition -> place", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Sink")], + transitions: [ + transition("Move", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const graph = buildNetGraph(net); + + expect(graph.nodes.map(({ id }) => id)).toEqual(["Source", "Sink", "Move"]); + expect(graph.edges).toEqual([ + { from: "Source", to: "Move" }, + { from: "Move", to: "Sink" }, + ]); + }); + + it("excludes types, parameters and equations", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Stock", { colorId: "colour" })], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + parameters: [parameter("rate", "growth_rate")], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: null, code: "" }, + ], + }; + + expect(buildNetGraph(net).nodes.map(({ id }) => id)).toEqual(["Stock"]); + }); + + it("skips arcs naming a place that no longer exists", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + transitions: [ + transition("Orphan", { + inputArcs: [{ placeId: "Missing", weight: 1, type: "standard" }], + }), + ], + }; + + expect(buildNetGraph(net).edges).toEqual([]); + }); +}); + +describe("buildDependentCounts", () => { + it("counts direct and transitive dependents down a chain", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Source"), place("Middle"), place("Sink")], + transitions: [ + transition("First", { + inputArcs: [{ placeId: "Source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Middle", weight: 1 }], + }), + transition("Second", { + inputArcs: [{ placeId: "Middle", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + }), + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + // Source -> First -> Middle -> Second -> Sink + expect(counts.get("Source")).toEqual({ direct: 1, transitive: 4 }); + expect(counts.get("Middle")).toEqual({ direct: 1, transitive: 2 }); + expect(counts.get("Sink")).toEqual({ direct: 0, transitive: 0 }); + }); + + it("excludes the cell itself when it sits in a cycle", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Pool")], + transitions: [ + transition("Churn", { + inputArcs: [{ placeId: "Pool", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Pool", weight: 1 }], + }), + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + expect(counts.get("Pool")).toEqual({ direct: 1, transitive: 1 }); + expect(counts.get("Churn")).toEqual({ direct: 1, transitive: 1 }); + }); + + it("counts a declaration's users across kinds", () => { + const net: ActiveNetDefinition = { + ...emptyNet, + places: [place("Stock", { colorId: "colour" })], + types: [ + { + id: "colour", + name: "Widget", + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, + ], + differentialEquations: [ + { id: "decay", name: "Decay", colorId: "colour", code: "" }, + ], + }; + + const counts = buildDependentCounts(buildConnectionIndex(net)); + + // The type is used by the place and the equation directly. + expect(counts.get("colour")).toEqual({ direct: 2, transitive: 2 }); + }); +}); + +describe("fuzzyMatchName", () => { + it("matches a subsequence case-insensitively and returns its indices", () => { + expect(fuzzyMatchName("rwm", "RawMaterial")).toEqual([0, 2, 3]); + }); + + it("ignores whitespace in the query", () => { + expect(fuzzyMatchName("raw mat", "RawMaterial")).toEqual([ + 0, 1, 2, 3, 4, 5, + ]); + }); + + it("returns null when a character has no match after the previous one", () => { + expect(fuzzyMatchName("lam", "Material")).toBeNull(); + }); + + it("returns indices into the original string when lowercasing changes its length", () => { + // "İ".toLowerCase() is two code units; the indices must still point into + // the original string. + expect(fuzzyMatchName("stan", "İstanbul")).toEqual([1, 2, 3, 4]); + }); + + it("matches a query character whose lowercase form expands", () => { + // Lowercasing the whole query up front would split "İ" into "i" + a + // combining dot and fail on the second step. + expect(fuzzyMatchName("İst", "İstanbul")).toEqual([0, 1, 2]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts new file mode 100644 index 00000000000..f42d15a92b4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-model.ts @@ -0,0 +1,535 @@ +/** + * Pure projections of an SDCPN into the notebook view's data: the flat, + * document-ordered list of cells, and the dependency graph the gutter lines + * and graph explorer are drawn from. + */ + +import type { ActiveNetDefinition } from "../../../react/state/active-net-context"; +import type { + Color, + DifferentialEquation, + InputArc, + OutputArc, + Parameter, + Place, + SelectionItem, + Transition, +} from "@hashintel/petrinaut-core"; + +export type NotebookCell = + | { kind: "place"; id: string; place: Place } + | { kind: "transition"; id: string; transition: Transition } + | { kind: "type"; id: string; color: Color } + | { kind: "differentialEquation"; id: string; equation: DifferentialEquation } + | { kind: "parameter"; id: string; parameter: Parameter }; + +export type NotebookCellKind = NotebookCell["kind"]; + +/** + * Cells in document order — the order entities are stored in the net + * definition. + */ +export function buildNotebookCells(net: ActiveNetDefinition): NotebookCell[] { + return [ + ...net.places.map( + (place): NotebookCell => ({ kind: "place", id: place.id, place }), + ), + ...net.transitions.map( + (transition): NotebookCell => ({ + kind: "transition", + id: transition.id, + transition, + }), + ), + ...net.types.map( + (color): NotebookCell => ({ kind: "type", id: color.id, color }), + ), + ...net.differentialEquations.map( + (equation): NotebookCell => ({ + kind: "differentialEquation", + id: equation.id, + equation, + }), + ), + ...net.parameters.map( + (parameter): NotebookCell => ({ + kind: "parameter", + id: parameter.id, + parameter, + }), + ), + ]; +} + +export function cellToSelectionItem(cell: NotebookCell): SelectionItem { + return { type: cell.kind, id: cell.id }; +} + +export function cellName(cell: NotebookCell): string { + switch (cell.kind) { + case "place": + return cell.place.name || cell.id; + case "transition": + return cell.transition.name || cell.id; + case "type": + return cell.color.name || cell.id; + case "differentialEquation": + return cell.equation.name || cell.id; + case "parameter": + return cell.parameter.name || cell.id; + } +} + +/** + * Case-insensitive fuzzy subsequence match of `query` against `name` + * (whitespace in the query is ignored). Returns the indices of the matched + * characters in `name`, or `null` when the query doesn't match. + */ +export function fuzzyMatchName(query: string, name: string): number[] | null { + const indices: number[] = []; + let searchFrom = 0; + // Lowercase one character at a time, on both sides: lowercasing a whole + // string can change its length (e.g. "İ" → "i̇"), which would split one + // typed query character into two match steps and leave the returned + // indices misaligned with the original name. + for (const char of query) { + if (char.trim() === "") { + continue; + } + const needle = char.toLowerCase(); + let index = -1; + for (let at = searchFrom; at < name.length; at += 1) { + if ( + name + .slice(at, at + char.length) + .toLowerCase() + .startsWith(needle) + ) { + index = at; + break; + } + } + if (index === -1) { + return null; + } + indices.push(index); + searchFrom = index + 1; + } + return indices; +} + +/** + * Resolve an arc to the plain place it connects to, or `null` for component + * port endpoints. + */ +export function arcPlaceId(arc: InputArc | OutputArc): string | null { + if (arc.endpoint) { + return arc.endpoint.kind === "place" ? arc.endpoint.placeId : null; + } + return arc.placeId ?? null; +} + +function dedupe(ids: string[]): string[] { + return [...new Set(ids)]; +} + +export function transitionInputPlaceIds(transition: Transition): string[] { + return dedupe( + transition.inputArcs + .map(arcPlaceId) + .filter((id): id is string => id !== null), + ); +} + +export function transitionOutputPlaceIds(transition: Transition): string[] { + return dedupe( + transition.outputArcs + .map(arcPlaceId) + .filter((id): id is string => id !== null), + ); +} + +// Keyed weakly by the net object: edits produce a new definition object, so +// each cache entry stays valid for as long as its net is reachable. Without +// this, every arc of every rendered summary rescans the place array. +const placeNamesByNet = new WeakMap>(); + +export function placeName(net: ActiveNetDefinition, placeId: string): string { + let names = placeNamesByNet.get(net); + if (names === undefined) { + names = new Map( + net.places.map((place) => [place.id, place.name || place.id]), + ); + placeNamesByNet.set(net, names); + } + return names.get(placeId) ?? placeId; +} + +/** A cell referenced as one end of a dependency edge. */ +export type NodeRef = { + type: NotebookCellKind; + id: string; + name: string; +}; + +/** + * What a cell depends on and what depends on it. Derived from a single edge + * list, so the two directions are always consistent with each other. + */ +export type CellConnections = { + upstream: NodeRef[]; + downstream: NodeRef[]; +}; + +export const noConnections = (): CellConnections => ({ + upstream: [], + downstream: [], +}); + +/** `dependent` needs `dependency` in order to be understood. */ +type DependencyEdge = { dependency: NodeRef; dependent: NodeRef }; + +function isIdentifierChar(char: string | undefined): boolean { + if (char === undefined) { + return false; + } + return ( + (char >= "a" && char <= "z") || + (char >= "A" && char <= "Z") || + (char >= "0" && char <= "9") || + char === "_" || + char === "$" + ); +} + +/** + * Best-effort textual check for a parameter reference in user code: the + * variable name must appear with no identifier character on either side, so + * `rate` matches in `rate * 2` but not in `growth_rate` or `$rate`. + */ +function codeReferences( + code: string | undefined, + variableName: string, +): boolean { + if (!code || !variableName) { + return false; + } + for ( + let at = code.indexOf(variableName); + at !== -1; + at = code.indexOf(variableName, at + 1) + ) { + if ( + !isIdentifierChar(code[at - 1]) && + !isIdentifierChar(code[at + variableName.length]) + ) { + return true; + } + } + return false; +} + +/** + * Every dependency relationship the notebook knows how to draw: + * + * - a transition depends on its input places, and its output places depend + * on it (so token flow reads left-to-right through the gutters); + * - a place depends on its token type, its differential equation, and any + * parameter its visualizer references; + * - a differential equation depends on its token type and the parameters its + * code references; + * - a transition depends on the parameters its lambda or kernel references. + * + * Types and parameters are pure declarations, so they never have upstream + * edges of their own. + */ +function buildDependencyEdges(net: ActiveNetDefinition): DependencyEdge[] { + const placeRefs = new Map( + net.places.map((place) => [ + place.id, + { type: "place", id: place.id, name: place.name || place.id }, + ]), + ); + const typeRefs = new Map( + net.types.map((color) => [ + color.id, + { type: "type", id: color.id, name: color.name || color.id }, + ]), + ); + const equationRefs = new Map( + net.differentialEquations.map((equation) => [ + equation.id, + { + type: "differentialEquation", + id: equation.id, + name: equation.name || equation.id, + }, + ]), + ); + + const edges: DependencyEdge[] = []; + const seen = new Set(); + + const add = ( + dependency: NodeRef | undefined, + dependent: NodeRef | undefined, + ) => { + if ( + dependency === undefined || + dependent === undefined || + dependency.id === dependent.id + ) { + return; + } + // Direction matters: a place that is both input and output of the same + // transition legitimately produces one edge each way. + const key = `${dependency.id} ${dependent.id}`; + if (seen.has(key)) { + return; + } + seen.add(key); + edges.push({ dependency, dependent }); + }; + + const parameterRefs = net.parameters.map((parameter) => ({ + parameter, + ref: { + type: "parameter" as const, + id: parameter.id, + name: parameter.name || parameter.id, + }, + })); + + const addParameterEdges = ( + dependent: NodeRef, + ...code: (string | undefined)[] + ) => { + for (const { parameter, ref } of parameterRefs) { + if ( + code.some((snippet) => codeReferences(snippet, parameter.variableName)) + ) { + add(ref, dependent); + } + } + }; + + for (const place of net.places) { + const dependent = placeRefs.get(place.id)!; + if (place.colorId !== null) { + add(typeRefs.get(place.colorId), dependent); + } + if (place.differentialEquationId !== null) { + add(equationRefs.get(place.differentialEquationId), dependent); + } + addParameterEdges(dependent, place.visualizerCode); + } + + for (const transition of net.transitions) { + const transitionRef: NodeRef = { + type: "transition", + id: transition.id, + name: transition.name || transition.id, + }; + for (const placeId of transitionInputPlaceIds(transition)) { + add(placeRefs.get(placeId), transitionRef); + } + for (const placeId of transitionOutputPlaceIds(transition)) { + add(transitionRef, placeRefs.get(placeId)); + } + addParameterEdges( + transitionRef, + transition.lambdaCode, + transition.transitionKernelCode, + ); + } + + for (const equation of net.differentialEquations) { + const dependent = equationRefs.get(equation.id)!; + if (equation.colorId !== null) { + add(typeRefs.get(equation.colorId), dependent); + } + addParameterEdges(dependent, equation.code); + } + + return edges; +} + +/** + * Upstream/downstream connections for every cell in the net, keyed by cell + * id. Built once per net so the gutter lines, the explorer and keyboard + * navigation all read the same graph. + */ +export function buildConnectionIndex( + net: ActiveNetDefinition, +): Map { + const index = new Map(); + + const entryFor = (id: string): CellConnections => { + const existing = index.get(id); + if (existing !== undefined) { + return existing; + } + const created = noConnections(); + index.set(id, created); + return created; + }; + + for (const { dependency, dependent } of buildDependencyEdges(net)) { + entryFor(dependent.id).upstream.push(dependency); + entryFor(dependency.id).downstream.push(dependent); + } + + return index; +} + +/** + * The selected node's immediate neighbourhood, restricted to the net's own + * nodes (places and transitions) and split by direction. + * + * A neighbour that is reachable both ways — a place that is an input *and* an + * output of the same transition, for instance — is a cycle through the centre + * and lands in `bidirectional` rather than being drawn twice. + */ +export type NodeNeighbourhood = { + dependencies: NodeRef[]; + dependents: NodeRef[]; + bidirectional: NodeRef[]; +}; + +const isNetNode = (ref: NodeRef): boolean => + ref.type === "place" || ref.type === "transition"; + +/** Deduplicate refs by id, keeping first occurrence order. */ +const uniqueRefs = (refs: NodeRef[]): NodeRef[] => { + const byId = new Map(); + for (const ref of refs) { + if (!byId.has(ref.id)) { + byId.set(ref.id, ref); + } + } + return [...byId.values()]; +}; + +export function buildNodeNeighbourhood( + connections: CellConnections, +): NodeNeighbourhood { + const upstream = uniqueRefs(connections.upstream.filter(isNetNode)); + const downstream = uniqueRefs(connections.downstream.filter(isNetNode)); + + const upstreamIds = new Set(upstream.map(({ id }) => id)); + const downstreamIds = new Set(downstream.map(({ id }) => id)); + + return { + dependencies: upstream.filter(({ id }) => !downstreamIds.has(id)), + dependents: downstream.filter(({ id }) => !upstreamIds.has(id)), + bidirectional: upstream.filter(({ id }) => downstreamIds.has(id)), + }; +} + +export type NetGraphNodeKind = "place" | "transition"; + +export type NetGraphNode = { + id: string; + name: string; + kind: NetGraphNodeKind; +}; + +/** A directed arc between two net nodes, in token-flow direction. */ +export type NetGraphEdge = { from: string; to: string }; + +export type NetGraph = { nodes: NetGraphNode[]; edges: NetGraphEdge[] }; + +/** + * The whole net as a directed graph of places and transitions: an input arc + * becomes place → transition, an output arc transition → place. Arcs to + * component ports, and arcs naming a place that no longer exists, are + * skipped. Only places and transitions appear — this graph describes token + * flow. + */ +export function buildNetGraph(net: ActiveNetDefinition): NetGraph { + const nodes: NetGraphNode[] = [ + ...net.places.map( + (place): NetGraphNode => ({ + id: place.id, + name: place.name || place.id, + kind: "place", + }), + ), + ...net.transitions.map( + (transition): NetGraphNode => ({ + id: transition.id, + name: transition.name || transition.id, + kind: "transition", + }), + ), + ]; + + const placeIds = new Set(net.places.map(({ id }) => id)); + const edges: NetGraphEdge[] = []; + const seen = new Set(); + + const add = (from: string, to: string) => { + const key = `${from} ${to}`; + if (seen.has(key)) { + return; + } + seen.add(key); + edges.push({ from, to }); + }; + + for (const transition of net.transitions) { + for (const placeId of transitionInputPlaceIds(transition)) { + if (placeIds.has(placeId)) { + add(placeId, transition.id); + } + } + for (const placeId of transitionOutputPlaceIds(transition)) { + if (placeIds.has(placeId)) { + add(transition.id, placeId); + } + } + } + + return { nodes, edges }; +} + +/** + * How much depends on a cell: `direct` counts its immediate dependents, + * `transitive` counts everything reachable downstream from it (itself + * excluded, so a cell inside a cycle doesn't count itself). + */ +export type DependentCount = { direct: number; transitive: number }; + +/** + * Dependent counts for every cell in the index. Cycles are handled by visiting + * each node at most once per traversal, so a loop contributes its members + * rather than looping forever. + */ +export function buildDependentCounts( + index: Map, +): Map { + const counts = new Map(); + + for (const [id, connections] of index) { + const reached = new Set(); + const queue = connections.downstream.map((ref) => ref.id); + + for (let head = 0; head < queue.length; head++) { + const next = queue[head]!; + if (next === id || reached.has(next)) { + continue; + } + reached.add(next); + for (const ref of index.get(next)?.downstream ?? []) { + queue.push(ref.id); + } + } + + counts.set(id, { + direct: connections.downstream.length, + transitive: reached.size, + }); + } + + return counts; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts new file mode 100644 index 00000000000..2014ed2dfda --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import { orderCellsTopologically } from "./notebook-order"; + +import type { CellConnections, NodeRef, NotebookCell } from "./notebook-model"; + +const placeCell = (id: string): NotebookCell => ({ + kind: "place", + id, + place: { + id, + name: id, + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, +}); + +const transitionCell = (id: string): NotebookCell => ({ + kind: "transition", + id, + transition: { + id, + name: id, + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, +}); + +const parameterCell = (id: string): NotebookCell => ({ + kind: "parameter", + id, + parameter: { + id, + name: id, + variableName: id, + type: "real", + defaultValue: "1", + }, +}); + +const typeCell = (id: string): NotebookCell => ({ + kind: "type", + id, + color: { + id, + name: id, + iconSlug: "circle", + displayColor: "#000000", + elements: [], + }, +}); + +const ref = (type: NodeRef["type"], id: string): NodeRef => ({ + type, + id, + name: id, +}); + +const connections = ( + entries: Record, +): Map => + new Map( + Object.entries(entries).map(([id, upstream]) => [ + id, + { upstream, downstream: [] }, + ]), + ); + +const ids = (cells: NotebookCell[]) => cells.map(({ id }) => id); + +describe("orderCellsTopologically", () => { + it("follows the supplied flow order for places and transitions", () => { + const cells = [ + transitionCell("Move"), + placeCell("Sink"), + placeCell("Source"), + ]; + + expect( + ids( + orderCellsTopologically(cells, ["Source", "Move", "Sink"], new Map()), + ), + ).toEqual(["Source", "Move", "Sink"]); + }); + + it("inlines a parameter immediately before its first user", () => { + const cells = [ + parameterCell("rate"), + placeCell("Source"), + transitionCell("Move"), + ]; + + const ordered = orderCellsTopologically( + cells, + ["Source", "Move"], + connections({ Move: [ref("parameter", "rate")] }), + ); + + expect(ids(ordered)).toEqual(["Source", "rate", "Move"]); + }); + + it("emits a declaration only once, before its earliest user", () => { + const cells = [ + parameterCell("rate"), + transitionCell("First"), + transitionCell("Second"), + ]; + + const ordered = orderCellsTopologically( + cells, + ["First", "Second"], + connections({ + First: [ref("parameter", "rate")], + Second: [ref("parameter", "rate")], + }), + ); + + expect(ids(ordered)).toEqual(["rate", "First", "Second"]); + }); + + it("emits a declaration's own dependencies before it", () => { + // Equation depends on a type and a parameter; a place uses the equation. + const cells = [ + placeCell("Stock"), + typeCell("Widget"), + parameterCell("decayRate"), + { + kind: "differentialEquation" as const, + id: "Decay", + equation: { id: "Decay", name: "Decay", colorId: null, code: "" }, + }, + ]; + + const ordered = orderCellsTopologically( + cells, + ["Stock"], + connections({ + Stock: [ref("differentialEquation", "Decay")], + Decay: [ref("type", "Widget"), ref("parameter", "decayRate")], + }), + ); + + expect(ids(ordered)).toEqual(["Widget", "decayRate", "Decay", "Stock"]); + }); + + it("keeps unused declarations, appended at the end", () => { + const cells = [parameterCell("unused"), placeCell("Source")]; + + expect(ids(orderCellsTopologically(cells, ["Source"], new Map()))).toEqual([ + "Source", + "unused", + ]); + }); + + it("never drops a cell missing from the flow order", () => { + const cells = [placeCell("Source"), placeCell("Detached")]; + + expect(ids(orderCellsTopologically(cells, ["Source"], new Map()))).toEqual([ + "Source", + "Detached", + ]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts new file mode 100644 index 00000000000..4ddf2de537f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Notebook/notebook-order.ts @@ -0,0 +1,83 @@ +/** + * Cell ordering for the notebook list. + * + * `document` keeps the net's own storage order. `topological` reads like a + * program: places and transitions follow token flow, and every declaration a + * cell needs — its token type, differential equation, parameters — is emitted + * immediately before the first cell that uses it. + */ + +import type { CellConnections, NotebookCell } from "./notebook-model"; + +export type CellOrder = "document" | "topological"; + +const DECLARATION_KINDS = new Set([ + "type", + "differentialEquation", + "parameter", +]); + +/** + * Order cells so nothing is referenced before it is declared. + * + * `flowOrder` supplies the place/transition sequence — pass the diagram's + * layer order so the list and the graph tell the same story. Cells missing + * from `flowOrder`, and declarations nothing uses, are appended in document + * order so no cell is ever dropped. + */ +export function orderCellsTopologically( + cells: NotebookCell[], + flowOrder: string[], + connections: Map, +): NotebookCell[] { + const cellById = new Map(cells.map((cell) => [cell.id, cell])); + const emitted = new Set(); + const emitting = new Set(); + const ordered: NotebookCell[] = []; + + const emit = (id: string) => { + const cell = cellById.get(id); + if (cell === undefined || emitted.has(id)) { + return; + } + emitted.add(id); + ordered.push(cell); + }; + + /** + * Emit the declarations a cell depends on, deepest first — an equation's own + * token type and parameters come before the equation itself. `emitting` + * guards against a malformed net looping back on itself. + */ + const emitDeclarations = (id: string) => { + if (emitting.has(id)) { + return; + } + emitting.add(id); + for (const dependency of connections.get(id)?.upstream ?? []) { + if ( + !DECLARATION_KINDS.has(dependency.type) || + emitted.has(dependency.id) + ) { + continue; + } + emitDeclarations(dependency.id); + emit(dependency.id); + } + emitting.delete(id); + }; + + for (const id of flowOrder) { + emitDeclarations(id); + emit(id); + } + + // Anything the flow order didn't reach: unused declarations, and nodes that + // aren't part of the graph at all. + for (const cell of cells) { + emitDeclarations(cell.id); + emit(cell.id); + } + + return ordered; +}