diff --git a/README.md b/README.md index 1243a47..95091a3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Configuration lives at exactly one of two levels: in the package (`eslint.config | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`graph-dag`](packages/core/graph/dag/README.md) | Model-independent dependency-graph primitives over a node set and a `needs` accessor: layers, ancestors, descendants, cycles, transitive reduction, critical path. | | [`graph-schema`](packages/core/graph/schema/README.md) | The process model: typed identifier spaces, `defineModel` with compile-time reference checking, structural validation. | -| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs. | +| [`graph-projection`](packages/core/graph/projection/README.md) | Projections of the model into a flowchart, swimlane, state machine and sequence diagram, plus derived hand-offs, and the inverses that turn an edited view into a diff against the model. | | [`graph-domain`](packages/core/graph/domain/README.md) | Reasoning over the model and a run overlay: control graph, illegal cycles, three-valued status, ready set, blocked-by, impact, what-if and critical path. | | [`graph-layout`](packages/core/graph/layout/README.md) | Layered layout of a projected flowchart, swimlane or state machine into positions in the model's `views` shape. | | [`graph-source-contract`](packages/core/graph/source-contract/README.md) | The `GraphSource` port as an oRPC contract: versioned, paged and streamed sync of bound activities, participants, transitions and run state, resumable by cursor, with an in-memory fake and a conformance kit. | diff --git a/packages/core/graph/dag/README.md b/packages/core/graph/dag/README.md index d351e1c..73dc1c3 100644 --- a/packages/core/graph/dag/README.md +++ b/packages/core/graph/dag/README.md @@ -40,15 +40,17 @@ Nothing is absorbed silently: ## API -| Function | Returns | With a cycle | -| --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| `buildDag(nodes, needs)` | An immutable `Dag`: nodes, needs, dependents, unknown references | Builds normally. | -| `topologicalLayers(dag)` | `levels`, `layers`, `unlayered` | Layers the acyclic part; nodes on a cycle or needing one are returned in `unlayered`. | -| `ancestorsOf(dag, id)` | Transitive needs, in node order | Includes `id` itself when it is on a cycle. | -| `descendantsOf(dag, id)` | Transitive dependents, in node order | Includes `id` itself when it is on a cycle. | -| `findCycles(dag)` | One closed path per cyclic strongly connected component | Not applicable; this is the report. | -| `transitiveReduction(dag)` | A `Dag` without redundant edges | Throws `CycleError`: with a cycle the reduction is not unique. | -| `criticalPath(dag, weight)` | `{ path, weight }`, the heaviest chain | Throws `CycleError`: a chain through a cycle has no greatest weight. | +| Function | Returns | With a cycle | +| ---------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `buildDag(nodes, needs)` | An immutable `Dag`: nodes, needs, dependents, unknown references | Builds normally. | +| `topologicalLayers(dag)` | `levels`, `layers`, `unlayered` | Layers the acyclic part; nodes on a cycle or needing one are returned in `unlayered`. | +| `ancestorsOf(dag, id)` | Transitive needs, in node order | Includes `id` itself when it is on a cycle. | +| `descendantsOf(dag, id)` | Transitive dependents, in node order | Includes `id` itself when it is on a cycle. | +| `findCycles(dag)` | One closed path per cyclic strongly connected component | Not applicable; this is the report. | +| `stronglyConnectedComponents(dag)` | Every strongly connected component, each node in exactly one, a component before those that need it | Not applicable; a cycle is a component of several nodes or of one node needing itself. | +| `immediateDominators(dag, root)` | Each node reachable from `root` along `needs` mapped to its immediate dominator | Allowed; dominance is defined on any graph. | +| `transitiveReduction(dag)` | A `Dag` without redundant edges | Throws `CycleError`: with a cycle the reduction is not unique. | +| `criticalPath(dag, weight)` | `{ path, weight }`, the heaviest chain | Throws `CycleError`: a chain through a cycle has no greatest weight. | `ancestorsOf` and `descendantsOf` throw `UnknownNodeError` for an id that is not a node. `criticalPath` throws `RangeError` for a negative or non-finite weight. diff --git a/packages/core/graph/dag/src/cycles.ts b/packages/core/graph/dag/src/cycles.ts index e4d17b7..5bf2489 100644 --- a/packages/core/graph/dag/src/cycles.ts +++ b/packages/core/graph/dag/src/cycles.ts @@ -7,9 +7,13 @@ interface TarjanFrame { } /** - * Every strongly connected component of the graph, found with an explicit stack so depth is bounded by memory, not by the call stack. + * Every strongly connected component of the graph: the maximal sets of nodes in which each node reaches every other along `needs` edges. Every node is in exactly one component, and a node on no cycle is a component of its own. Found by Tarjan's algorithm with an explicit stack, so depth is bounded by memory, not by the call stack, and each node and edge is visited once. + * + * A component lists its nodes in the order Tarjan's algorithm completes them, its root last. Components come out in reverse topological order of the condensed graph along `needs`: a component appears before every component that needs it. Since mutual reachability is symmetric, the components are the same whichever direction the edges are read in. + * @param dag - The graph to partition. + * @returns Every component, each a non-empty list of nodes. */ -function stronglyConnectedComponents( +export function stronglyConnectedComponents( dag: Dag, ): readonly (readonly Id[])[] { const index = new Map(); diff --git a/packages/core/graph/dag/src/cycles.unit.test.ts b/packages/core/graph/dag/src/cycles.unit.test.ts index f0a5443..ca7d3d5 100644 --- a/packages/core/graph/dag/src/cycles.unit.test.ts +++ b/packages/core/graph/dag/src/cycles.unit.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { assertAcyclic, findCycles } from "./cycles.ts"; +import { + assertAcyclic, + findCycles, + stronglyConnectedComponents, +} from "./cycles.ts"; import { CycleError } from "./errors.ts"; import { dagOf } from "./test-support/dag-of.ts"; @@ -130,3 +134,34 @@ describe("assertAcyclic", () => { ]); }); }); + +describe("stronglyConnectedComponents", () => { + it("puts every node in exactly one component, mutually reachable nodes together", () => { + const components = stronglyConnectedComponents( + dagOf({ p: ["q"], q: ["p"], m: [], r: ["s"], s: ["r", "t"], t: ["t"] }), + ); + + const sorted = components + .map((component) => component.toSorted((a, b) => a.localeCompare(b))) + .toSorted((a, b) => a.join().localeCompare(b.join())); + + expect(sorted).toEqual([["m"], ["p", "q"], ["r", "s"], ["t"]]); + }); + + it("lists a component before every component that needs it", () => { + expect( + stronglyConnectedComponents(dagOf({ a: [], b: ["a"], c: ["b"] })), + ).toEqual([["a"], ["b"], ["c"]]); + }); + + it("partitions a ring deeper than any call stack without recursing", () => { + const ring = Object.fromEntries( + Array.from({ length: deepRingLength }, (_, index) => [ + `n${String(index)}`, + [`n${String((index + 1) % deepRingLength)}`], + ]), + ); + + expect(stronglyConnectedComponents(dagOf(ring))).toHaveLength(1); + }); +}); diff --git a/packages/core/graph/dag/src/dominators.ts b/packages/core/graph/dag/src/dominators.ts new file mode 100644 index 0000000..a44df95 --- /dev/null +++ b/packages/core/graph/dag/src/dominators.ts @@ -0,0 +1,114 @@ +import { type Dag, type NodeId, lookup } from "./dag.ts"; + +/** + * The immediate dominator of every node reachable from `root` along `needs` edges. A node `d` dominates `n` when every path from `root` to `n` passes through `d`; the immediate dominator is the dominator closest to `n` other than `n` itself. Read with `needs` edges pointing from a step to the steps before it and `root` a virtual end every last step needs, this gives post-dominators: where every way forward from a step meets again. + * + * Found by the Lengauer-Tarjan algorithm with path compression, so the work is the number of reachable nodes and edges times a slowly growing factor, and every walk uses an explicit stack, so depth is bounded by memory, not the call stack. Cycles are allowed. + * @param dag - The graph to read. + * @param root - Where every path starts. + * @returns Each node reachable from `root` other than `root`, mapped to its immediate dominator, in the order a depth-first walk from `root` first reaches the nodes. A node `root` does not reach has no entry. + * @throws UnknownNodeError when `root` is not a node of `dag`. + */ +export function immediateDominators( + dag: Dag, + root: Id, +): ReadonlyMap { + lookup(dag.needs, root); + + // Depth-first numbering from the root: `order[i]` is the node numbered i, and `parent[i]` the number of the node it was reached from. + const number = new Map(); + const order: Id[] = []; + const parent: number[] = []; + const frames: { readonly node: Id; next: number }[] = []; + const enter = (node: Id, from: number): void => { + number.set(node, order.length); + order.push(node); + parent.push(from); + frames.push({ node, next: 0 }); + }; + enter(root, -1); + for (let frame = frames.at(-1); frame !== undefined; frame = frames.at(-1)) { + const target = lookup(dag.needs, frame.node)[frame.next]; + if (target === undefined) { + frames.pop(); + continue; + } + frame.next += 1; + if (!number.has(target)) { + enter(target, lookup(number, frame.node)); + } + } + + // Predecessors of each numbered node among the numbered nodes, by number. + const predecessors: number[][] = order.map(() => []); + for (const [index, node] of order.entries()) { + for (const target of lookup(dag.needs, node)) { + predecessors[lookup(number, target)]?.push(index); + } + } + + const semi = order.map((_, index) => index); + const idom = order.map(() => 0); + const ancestor = order.map(() => -1); + const label = order.map((_, index) => index); + const bucket: number[][] = order.map(() => []); + + /** Path compression up the forest built by `link`, done with an explicit stack. */ + const compress = (start: number): void => { + const path: number[] = []; + for ( + let node = start; + (ancestor[ancestor[node] ?? -1] ?? -1) !== -1; + node = ancestor[node] ?? -1 + ) { + path.push(node); + } + for (const node of path.toReversed()) { + const up = ancestor[node] ?? -1; + const upLabel = label[up] ?? up; + if ((semi[upLabel] ?? 0) < (semi[label[node] ?? node] ?? 0)) { + label[node] = upLabel; + } + ancestor[node] = ancestor[up] ?? -1; + } + }; + /** The node with the least semidominator on the forest path to `node`. */ + const evaluate = (node: number): number => { + if (ancestor[node] === -1) { + return node; + } + compress(node); + + return label[node] ?? node; + }; + + for (let index = order.length - 1; index > 0; index -= 1) { + for (const from of predecessors[index] ?? []) { + const least = semi[evaluate(from)] ?? index; + if (least < (semi[index] ?? index)) { + semi[index] = least; + } + } + bucket[semi[index] ?? index]?.push(index); + const up = parent[index] ?? 0; + ancestor[index] = up; + for (const waiting of bucket[up] ?? []) { + const least = evaluate(waiting); + idom[waiting] = (semi[least] ?? 0) < (semi[waiting] ?? 0) ? least : up; + } + bucket[up] = []; + } + const dominators = new Map(); + for (let index = 1; index < order.length; index += 1) { + if (idom[index] !== semi[index]) { + idom[index] = idom[idom[index] ?? 0] ?? 0; + } + const node = order[index]; + const dominator = order[idom[index] ?? 0]; + if (node !== undefined && dominator !== undefined) { + dominators.set(node, dominator); + } + } + + return dominators; +} diff --git a/packages/core/graph/dag/src/dominators.unit.test.ts b/packages/core/graph/dag/src/dominators.unit.test.ts new file mode 100644 index 0000000..bcb1e6b --- /dev/null +++ b/packages/core/graph/dag/src/dominators.unit.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { type Dag, lookup } from "./dag.ts"; +import { immediateDominators } from "./dominators.ts"; +import { dagOf } from "./test-support/dag-of.ts"; + +/** Far deeper than any call stack tolerates in a recursive walk, so an accidental recursion fails here. */ +const deepChainLength = 100_000; +/** Enough small random graphs to meet diamonds, loops, irreducible cycles and unreachable nodes. */ +const randomGraphs = 300; +/** Nodes per random graph: small enough that the reference's set intersections stay cheap. */ +const randomNodes = 9; +/** Edges per node, on average, in a random graph: dense enough for several paths between most nodes. */ +const randomDensity = 2; + +/** + * The reference: every node's dominators as a set, by the textbook fixed point (a node's dominators are itself plus the intersection of its predecessors'), then the immediate dominator as the strict dominator that every other strict dominator dominates. + */ +const referenceDominators = ( + dag: Dag, + root: string, +): ReadonlyMap => { + const reachable = new Set([root]); + const stack = [root]; + for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + for (const target of lookup(dag.needs, next)) { + if (!reachable.has(target)) { + reachable.add(target); + stack.push(target); + } + } + } + const nodes = dag.nodes.filter((node) => reachable.has(node)); + const predecessors = new Map( + nodes.map((node) => [node, []]), + ); + for (const node of nodes) { + for (const target of lookup(dag.needs, node)) { + lookup(predecessors, target).push(node); + } + } + const dominators = new Map>( + nodes.map((node) => [ + node, + node === root ? new Set([root]) : new Set(nodes), + ]), + ); + for (let changed = true; changed;) { + changed = false; + for (const node of nodes) { + if (node === root) { + continue; + } + const sets = lookup(predecessors, node).map((from) => + lookup(dominators, from), + ); + const next = new Set([ + node, + ...[...(sets[0] ?? [])].filter((candidate) => + sets.every((set) => set.has(candidate)), + ), + ]); + if (next.size !== lookup(dominators, node).size) { + dominators.set(node, next); + changed = true; + } + } + } + const immediate = new Map(); + for (const node of nodes) { + const strict = [...lookup(dominators, node)].filter( + (other) => other !== node, + ); + const closest = strict.find((candidate) => + strict.every((other) => lookup(dominators, candidate).has(other)), + ); + if (closest !== undefined) { + immediate.set(node, closest); + } + } + + return immediate; +}; + +/** The multiplier, increment and modulus of the ANSI C linear congruential generator, a well-known full-period choice. */ +const lcgMultiplier = 1_103_515_245; +const lcgIncrement = 12_345; +const lcgModulus = 2_147_483_648; + +/** A deterministic sequence of pseudo-random integers below `bound`, so a failing graph can be rebuilt. */ +const sequence = (seed: number): ((bound: number) => number) => { + let state = seed; + + return (bound) => { + state = (state * lcgMultiplier + lcgIncrement) % lcgModulus; + + return state % bound; + }; +}; + +const randomGraph = (seed: number): Dag => { + const next = sequence(seed); + const names = Array.from( + { length: randomNodes }, + (_, index) => `n${String(index)}`, + ); + const table: Record = Object.fromEntries( + names.map((name) => [name, []]), + ); + for (let edge = 0; edge < randomNodes * randomDensity; edge += 1) { + const from = names[next(randomNodes)]; + const to = names[next(randomNodes)]; + if (from !== undefined && to !== undefined) { + table[from]?.push(to); + } + } + + return dagOf(table); +}; + +describe("immediateDominators", () => { + it("finds where both sides of a diamond meet", () => { + expect( + immediateDominators( + dagOf({ + start: ["left", "right"], + left: ["join"], + right: ["join"], + join: [], + }), + "start", + ), + ).toEqual( + new Map([ + ["left", "start"], + ["join", "start"], + ["right", "start"], + ]), + ); + }); + + it("dominates a loop's body by its entry and leaves unreachable nodes out", () => { + expect( + immediateDominators( + dagOf({ a: ["b"], b: ["c", "d"], c: ["b"], d: [], orphan: ["a"] }), + "a", + ), + ).toEqual( + new Map([ + ["b", "a"], + ["c", "b"], + ["d", "b"], + ]), + ); + }); + + it("agrees with the textbook fixed point on many random graphs", () => { + for (let seed = 1; seed <= randomGraphs; seed += 1) { + const graph = randomGraph(seed); + + expect(immediateDominators(graph, "n0")).toEqual( + referenceDominators(graph, "n0"), + ); + } + }); + + it("walks a chain deeper than any call stack without recursing", () => { + const table = Object.fromEntries( + Array.from({ length: deepChainLength }, (_, index) => [ + `n${String(index)}`, + index + 1 < deepChainLength ? [`n${String(index + 1)}`] : [], + ]), + ); + const dominators = immediateDominators(dagOf(table), "n0"); + + expect(dominators.get(`n${String(deepChainLength - 1)}`)).toBe( + `n${String(deepChainLength - 2)}`, + ); + }); +}); diff --git a/packages/core/graph/dag/src/index.ts b/packages/core/graph/dag/src/index.ts index f4f59e4..69535f3 100644 --- a/packages/core/graph/dag/src/index.ts +++ b/packages/core/graph/dag/src/index.ts @@ -7,6 +7,7 @@ export { export { CycleError, UnknownNodeError } from "./errors.ts"; export { topologicalLayers, type TopologicalLayers } from "./layers.ts"; export { ancestorsOf, descendantsOf } from "./reachability.ts"; -export { findCycles } from "./cycles.ts"; +export { findCycles, stronglyConnectedComponents } from "./cycles.ts"; +export { immediateDominators } from "./dominators.ts"; export { transitiveReduction } from "./reduction.ts"; export { criticalPath, type CriticalPath } from "./critical-path.ts"; diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 1d01796..e59567f 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -10,7 +10,7 @@ Projections of the process model from `@exadev/process-graph-schema` into each n | `toFlowchart(model)` | none | Nodes (process, decision, terminator, bar) and guarded edges. | | `toSwimlane(model)` | none | The flowchart plus lanes by actor, unassigned nodes, hand-offs. | | `toStateMachine(model, subject)` | a subject entity | States and transitions with decisions folded into guards. | -| `toSequence(model, scenario)` | a path of transition ids | Lifelines in order of first appearance and messages. | +| `toSequence(model, scenario)` | a path of transition ids | Lifelines in order of first appearance, messages and fragments. | ```ts import { handoffs, toSequence } from "@exadev/process-graph-projection"; @@ -21,7 +21,17 @@ toSequence(model, ["t1", "t2"]); ## Hand-offs are derived -A transition between two elements with different actors is a hand-off, never stored. Elements without an actor never produce one. +A transition between two elements with different actors is a hand-off, never stored. Elements without an actor never produce one. A transition into a `message` element continues through it to the step after it, so a message between two steps is part of the hand-off it carries (listed in `through`), not an end of it. + +## Message elements + +A `message` element is read by every projection. A sequence step arriving at one draws it with its own `from`, `to`, `label` and `mode`, naming it in `element`, and the step leaving it continues the same message. A flowchart or swimlane has no node for it: the edge from the step before it to the step after it carries it (`through`), labelled with the message unless a step on it is guarded. A state machine folds it the way it folds a decision, and its `event` triggers the transition that carries it. Only a message element that nothing carries is reported as a `message-elements` loss; one that folding would turn into more edges than it removes is not folded and is reported as unrepresented instead (see Cost). + +Flowchart edges and state machine transitions list the model `transitions` they stand for, and the elements folded into them in `through`, so an edit to them maps back to the model. + +Routes through folded elements are collapsed, never enumerated. From each transition leaving a drawn element, a flowchart edge, hand-off or state machine transition is drawn once for each drawn element it leads to, however many routes connect them. Its `transitions` and `through` are the union over those routes, each listed once in the order a depth-first walk in model order first meets them, and they, with an edge's `label` and a state machine transition's `guard`, are built only when first read: a run of folded elements shared by many chains is held once, not copied into each. Only folded elements that lead to some drawn element are ever walked, found once by walking back from the drawn elements, so a run that leads nowhere costs nothing. Where routes merge, the walk over what a start's component reaches is done once for that component and indexed by where each transition arrives, so reading one chain's union walks back over that union alone. + +Each projection indexes the folded elements once. They are condensed into strongly connected components, and a component gets a flat array of the drawn elements it leads to only when something reads it on its own: it is entered from a drawn element, or from two or more components. Every other component stands on the one before it and is read as part of that one, so it is visited once in all. Arrays are built on demand and remembered, so a region nothing reaches costs nothing. A target is listed once, at its last place in reading order (a component's own targets, then each way on's in order), so the own targets nothing further on reaches come first. Duplicates are removed with a generation stamp per target; a component with nothing to add shares the one array it reads, and the only reader of an array nothing else reads extends it in place. The work is therefore linear in the elements and transitions plus the sizes of the arrays that must be copied, which happens only where two or more starts, or two or more components, read the same targets; reading a start's targets walks only its own array. A chain is a single route when every folded element on its way back from its end, and its end, is entered from exactly one folded element outside any cycle; those elements form a forest, and each records the last trigger, effect and guard above it, so a single route's event, effect and whether it is guarded are found from its two ends. On a single route the guards are joined with "and" and the last trigger and effect are kept; where routes merge their guards, triggers and effects belong to different routes, so a flowchart edge is labelled with the messages it carries and a state machine transition keeps only the target's entry action. ## Swimlane lanes @@ -33,30 +43,82 @@ A transition between two elements with different actors is a hand-off, never sto When no activity in the model declares `affects`, the model carries no such information and every activity is kept. Once any activity declares it, an activity without `affects` is not kept (an empty list is a declaration that it affects nothing). +## Sequence messages + +A step between two elements with actors is a message, labelled by its trigger's event name, or else by its target's label. An element with no actor that the scenario passes through, such as a fork or join, passes on whoever reached it, so the step leaving it is sent by the actor of the step that arrived. A step that still has no sender, or arrives at an element with no actor, draws nothing and is reported as a skipped step. + ## Sequence fragments -`toSequence` returns `messages`, the flat list in scenario order, and `items`, the same messages with the authored `fragments` wrapped around the ones that fall inside them. An item is a `message` or a `fragment` carrying its `operator` and the `operands` the scenario takes, each with its guard and its own items. Fragments are never invented: every one drawn is authored. +`toSequence` returns `messages`, the flat list in scenario order, and `items`, the same messages with fragments wrapped around the ones that fall inside them. An item is a `message` or a `fragment` carrying its `operator` and the `operands` the scenario takes, each with its guard and its own items. An authored fragment names itself in `fragment`; a derived one names the decision or fork it comes from in `element`. + +Fragments are derived from the model's structure as the scenario passes its decisions and forks: + +| Structure | Fragment | Holds | +| ---------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- | +| A decision's transitions whose target can reach the decision again | `loop` | The messages arriving inside the decision's cycle, guarded by those transitions' guards joined with "or" | +| Taking one of a decision's other (forward) branches, when it has two or more | `alt` | The messages from that step until the path reaches where the branches meet | +| The same, with exactly two branches, one going straight to where they meet | `opt` | As for `alt`; taking the empty branch draws nothing | +| Leaving a fork pseudo-state | `par` | One operand per branch taken, each with the messages that follow on from it until its branch reaches the join | -A message belongs to an operand when it arrives at an element in that operand's body. A fragment is drawn when at least one operand has a body wholly on the scenario path and receives a message, and its messages are consecutive in the scenario. An operand the scenario does not take is not drawn (an `alt` shows the branch taken, and the other branch is among the `unshown-branches`). A fragment nests inside another when all its messages lie within one of that fragment's operands. One that cannot be drawn faithfully, being interleaved with messages outside it, off the path or overlapping another without nesting, is left out and reported as a `fragments` loss. +A loop transition is the same one `graph-domain` treats as a legal cycle: its target is in the decision's strongly connected component. A component draws one loop however many ways round it has. Where branches meet is the decision's or fork's immediate post-dominator over forward transitions; branches that never meet run to the end of the path, and the path coming back to the decision ends the earlier `alt`. A branch of a fork taken again starts a new `par`. One pass finds every component and one every post-dominator, and the path is read once, so the work grows with the model and the path, not their product. A derived fragment holding exactly the messages of an authored one is not drawn: the authored fragment is the author's notation for the same structure. + +An authored fragment's operand holds the messages arriving at an element of its body, when that body is non-empty and wholly on the scenario path; a message is held by the first such operand of a fragment. The positions arriving at each element are found once and shared by every operand whose body holds that element, so matching costs the messages plus the bodies, never a message once per fragment. A fragment is drawn when it holds a message and its messages are consecutive in the scenario, and only the operands that hold messages are drawn (an `alt` shows the branch taken, and the other branch is among the `unshown-branches`). Larger fragments are placed first, and a fragment nests inside another when all its messages lie within one of that fragment's operands. Whether a fragment fits inside an operand is answered in constant or logarithmic time: a fragment with one operand holds its whole span, a run or list of positions is checked by its ends or by binary search, and an operand held by elements keeps a set of them, against which an authored fragment inside it is checked by its own elements. One that cannot be drawn faithfully, being interleaved with messages outside it, off the path or overlapping another without nesting, is left out, and an authored one left out is reported as a `fragments` loss. A derived `alt` or `opt` holds its messages as the ends of their run, never as a list of positions, and fragments are placed by the ends of their spans: a segment tree records when each was placed over the nodes covering its span, and the innermost fragment at a position is the latest placement on that position's path to the root. Placing every fragment and building the items therefore take the number of messages plus the number of fragments, each times the logarithm of the number of messages, however deeply fragments nest. ## Loss reports Every projection result carries `loss`, what it did not carry over from the model. Each `Loss` is a discriminated union on `kind`, reported only when something was actually dropped, in this order: -| `kind` | Dropped | Reported by | -| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| `layout` | Positions recorded in `views` for this projection's own notation. | every projection | -| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection | -| `message-elements` | `message` elements, which no projection draws. | every projection | -| `fragments` | Authored fragments the projection does not draw. | every projection | -| `unrepresented-elements` | Elements the notation has no way to draw (`state`, `history`, and in a state machine `fork` and `join`), with the transitions touching them. | `toFlowchart`, `toSwimlane`, `toStateMachine` | -| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` | -| `scenario` | Elements of the model that are not on the scenario. | `toSequence` | -| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` | -| `skipped-steps` | Scenario steps that touch an element with no actor and so draw no message. | `toSequence` | +| `kind` | Dropped | Reported by | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | +| `layout` | Positions recorded in `views` for this projection's own notation. | every projection | +| `extensions` | Notation-only `extensions` on elements, which no projection shows. | every projection | +| `message-elements` | `message` elements the projection neither draws nor folds into an edge or transition. | every projection | +| `fragments` | Authored fragments the projection does not draw. | every projection | +| `unrepresented-elements` | Elements the notation has no way to draw (`state`, `history`, in a state machine `fork` and `join`, and an element whose folding would multiply edges), with the transitions touching them. | `toFlowchart`, `toSwimlane`, `toStateMachine` | +| `subject` | Activities left out because they do not affect the state machine's subject. | `toStateMachine` | +| `scenario` | Elements of the model that are not on the scenario. | `toSequence` | +| `unshown-branches` | Transitions leaving an element on the scenario that the scenario does not take. | `toSequence` | +| `skipped-steps` | Scenario steps that touch an element with no actor and so draw no message. | `toSequence` | Decisions a state machine folds into guards are by design and not a loss. A flowchart drops ownership, a state machine needs its subject recorded, and a sequence diagram follows one path, so one model yields a different diagram per scenario. +## Inverses and view edits + +Each view has an inverse that takes the view after an edit and returns a `ModelDiff`: the element, transition and event changes the edit implies, matched by identifier. An edit that introduces identifiers the model does not declare works in the open identifier space (`OpenIds`). + +| Function | Reads | +| --------------------------------------- | ----------------------------------------------------------- | +| `diffFlowchart(model, edited)` | Nodes and edges. | +| `diffSwimlane(model, edited)` | Nodes, edges and which lane each node is in. | +| `diffStateMachine(model, edited)` | States and transitions, for the projection's `subject`. | +| `diffSequence(model, scenario, edited)` | Messages, matched by transition occurrence on the scenario. | + +Element changes are `added`, `removed`, `renamed`, `relaned` (another actor's lane) and `readdressed` (a message element between other actors); transition changes are `added`, `removed`, `rewired` (other ends) and `changed` (its `guard`, `trigger` or `effect`); an event can be `renamed`. Every change carries `inferred`, which is true when the view did not determine it and the inverse chose: a new terminator's or bar's role, an edit to a flowchart edge or state machine transition that stands for a chain through folded elements (its later steps are shared with other chains, so removing it, moving its target or changing its labels acts on each step from a folded element into its end, once however many chains share it), and almost everything a sequence diagram implies, since it shows one path and not who owns which step. A new sequence message names the element it arrives at in `element`, a new activity owned by its receiver unless that element exists, and leaves from where the latest earlier message to its sender arrived. + +Edges and transitions are matched by where they come from: the transition they start with and the drawn element one of their transitions arrives at. Omitted or moved edges and transitions through folded elements are grouped by the end they share, so the steps into each end are removed, rewired or changed once, and edits to the same end that disagree are refused. The inverses read the edited view once and the model through the projection's own index, and `applyModelDiff` groups the changes by what they are about once, so each does work in proportion to the model and the edit. + +`confirmChanges(diff, accept)` marks the inferred changes `accept` chooses as confirmed. `applyModelDiff(model, diff)` returns the changed model and throws `UnconfirmedChangeError` while any change is still inferred; removing an element also takes it out of fragment bodies and recorded positions, and removing a transition takes it out of every scenario. An edit the model cannot express, such as a changed node shape, a relabelled terminator or a lane for an entity, throws instead of being dropped. + +```ts +import { + applyModelDiff, + confirmChanges, + diffSequence, +} from "@exadev/process-graph-projection"; + +const diff = diffSequence(model, scenario, editedDiagram); +const next = applyModelDiff( + model, + confirmChanges(diff, () => true), +); +``` + +## Cost + +Folding never multiplies edges. Folding an element replaces the routes through it with one chain per pair of a way in and a drawn element it leads to, so through a folded region it can draw entering times targets chains where the model had entering plus targets transitions; an element is folded only when that product is no more than that sum (one side is a single item, or both are two), counted as the distinct entering transitions that reach it and the distinct drawn elements it reaches through folded elements. Otherwise it is not folded and the notation reports it, with the transitions touching it, as an `unrepresented-elements` loss, as for any element it has no shape for. Many sources through one message, or a run of messages, to many ends therefore draw nothing through it rather than every source to every end, and what every projection draws stays linear in the model. The counts are capped small sets carried once each way over the condensed folded graph, so the rule itself costs linear work. + +Everything a projection or inverse does before anyone reads its result is linear in the model, the scenario and the edit: the folded elements that lead somewhere, their components, the route forest, the post-dominators, the authored-fragment index, one read of the path, fragment placement (a logarithmic factor per fragment) and the grouping of an edit, each visit an element or transition a fixed number of times. What remains grows with output, which no algorithm can avoid producing. An edge's or transition's lists and label are built when read and cost their own length, so a long route fanning out to many ends costs that route's length per edge because every edge lists it. The work that is neither linear nor output-sized is inherent to listing reachability through shared folded regions. A target array is copied where two or more starts or components read the same targets, and merged chains from one start walk once what that start reaches among elements that lead somewhere. Both are bounded by the size of the target lists that must exist, which for a start reaching many drawn elements through folded regions shared with other starts is the answer itself, and removing duplicates where branches merge cannot cost less than the lists being merged. The scaling table in `bounded-work.unit.test.ts` checks every shape found so far against this model, per item of input and output. + ## Commands ```sh diff --git a/packages/core/graph/projection/package.json b/packages/core/graph/projection/package.json index b4a5dcc..087cdbc 100644 --- a/packages/core/graph/projection/package.json +++ b/packages/core/graph/projection/package.json @@ -69,6 +69,7 @@ "vitest": "5.0.3" }, "dependencies": { + "@exadev/process-graph-dag": "1.0.2", "@exadev/process-graph-schema": "1.1.0" } } diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts new file mode 100644 index 0000000..cc201a1 --- /dev/null +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -0,0 +1,824 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { sequenceObserved } from "./sequence.ts"; +import { workOf } from "./test-support/work.ts"; +import { + actors, + authoredNestingShape, + authoredRepeatsShape, + combShape, + diamondShape, + empty, + ids, + serialDiamondsShape, + sharedEndShape, + fanThroughShape, + sharedSuffixShape, + sourcesIntoRunShape, + twoBranchesShape, + wideForkShape, + type Shape, +} from "./test-support/shapes.ts"; + +import { + applyModelDiff, + diffFlowchart, + diffStateMachine, + handoffs, + toFlowchart, + toSequence, + toStateMachine, + toSwimlane, + type FlowEdge, + type FlowNode, + type ModelDiff, +} from "./index.ts"; + +/** + * The work these tests measure is the number of item reads through a counting proxy. A fixed number of passes over the input reads exactly twice as many items when the input doubles; a scan of the whole input per item reads four times as many. Each test therefore compares a size with its double rather than holding a threshold. + */ +const size = 400; +/** When the input doubles, a fixed number of linear passes doubles the reads and a pass per item quadruples them; this lies midway, so growth below it is linear up to constant overheads and growth at or above it is quadratic. */ +const growthLimit = 3; + +/** A copy of `items` whose indexed reads are counted, and the count so far. */ +const counted = ( + items: readonly T[], +): { readonly items: readonly T[]; readonly reads: () => number } => { + let reads = 0; + const proxy = new Proxy([...items], { + get: (target, key, receiver): unknown => { + if (typeof key === "string" && /^\d+$/u.test(key)) { + reads += 1; + } + + return Reflect.get(target, key, receiver); + }, + }); + + return { items: proxy, reads: () => reads }; +}; + +/** Edge reads `diffFlowchart` makes for an edit adding `count` bars joined in a ring. */ +const barEdgeReads = (count: number): number => { + const bars = ids(count, "bar-"); + const nodes: FlowNode[] = bars.map((id) => ({ + id, + label: "Fork", + shape: "bar", + })); + // A ring, so the edges are exactly as many as the bars. + const edges = counted>( + bars.map((id, index) => ({ + from: id, + to: bars[(index + 1) % count] ?? id, + transitions: [`t-${id}`], + })), + ); + diffFlowchart(empty, { nodes, edges: edges.items }); + + return edges.reads(); +}; + +/** Change reads `applyModelDiff` makes renaming every one of `count` activities. */ +const renameReads = (count: number): number => { + const activities = ids(count, "a-"); + const model: Model = { + ...empty, + elements: activities.map((id) => ({ kind: "activity", id, label: id })), + }; + const changes = counted["elements"][number]>( + activities.map((id) => ({ + kind: "renamed", + element: id, + from: id, + to: `${id}!`, + inferred: false, + })), + ); + applyModelDiff(model, { + elements: changes.items, + transitions: [], + events: [], + }); + + return changes.reads(); +}; + +describe("work grows in proportion to the input", () => { + it("diffFlowchart reads the edited edges a fixed number of times however many bars are added", () => { + expect(barEdgeReads(2 * size)).toBe(2 * barEdgeReads(size)); + }); + + it("applyModelDiff reads each change a fixed number of times however many elements change", () => { + expect(renameReads(2 * size)).toBe(2 * renameReads(size)); + }); +}); + +/** A model whose transitions count every property read, and the count so far. */ +const countingTransitions = ( + model: Model, +): { readonly model: Model; readonly reads: () => number } => { + let reads = 0; + + return { + model: { + ...model, + transitions: model.transitions.map( + (transition) => + new Proxy(transition, { + get: (target, key, receiver): unknown => { + reads += 1; + + return Reflect.get(target, key, receiver); + }, + }), + ), + }, + reads: () => reads, + }; +}; + +/** One decision with `count` forward branches that all lead into the same tail of `count` steps, and the path that takes the first branch down the tail. */ +const sharedTail = ( + count: number, +): { readonly model: Model; readonly path: string[] } => { + const tail = ids(count, "tail-"); + const model: Model = { + ...empty, + participants: [{ id: "shop", label: "Shop", kind: "system" }], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "shop" }, + { kind: "decision", id: "d", label: "D", actor: "shop" }, + ...tail.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "shop", + })), + ], + transitions: [ + { id: "t-in", source: "a", target: "d" }, + ...tail.map((id) => ({ id: `t-d-${id}`, source: "d", target: id })), + ...tail.slice(1).map((id, index) => ({ + id: `t-${id}`, + source: tail[index] ?? id, + target: id, + })), + ], + }; + + return { + model, + path: ["t-in", "t-d-tail-0", ...tail.slice(1).map((id) => `t-${id}`)], + }; +}; + +/** One decision with `count` ways round and back to it and two ways out, and the path that goes round once and leaves. */ +const manyLoops = ( + count: number, +): { readonly model: Model; readonly path: string[] } => { + const branches = ids(count, "l-"); + const model: Model = { + ...empty, + participants: [{ id: "shop", label: "Shop", kind: "system" }], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "shop" }, + { kind: "decision", id: "d", label: "D", actor: "shop" }, + ...branches.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "shop", + })), + { kind: "activity", id: "x", label: "X", actor: "shop" }, + { kind: "activity", id: "y", label: "Y", actor: "shop" }, + ], + transitions: [ + { id: "t-in", source: "a", target: "d" }, + ...branches.flatMap((id) => [ + { id: `t-${id}`, source: "d", target: id, guard: "again" }, + { id: `t-${id}-back`, source: id, target: "d" }, + ]), + { id: "t-x", source: "d", target: "x", guard: "done" }, + { id: "t-y", source: "d", target: "y", guard: "gave up" }, + ], + }; + + return { model, path: ["t-in", "t-l-0", "t-l-0-back", "t-x"] }; +}; + +const sequenceReads = ( + build: (count: number) => { + readonly model: Model; + readonly path: string[]; + }, + count: number, + walk: boolean, +): number => { + const { model, path } = build(count); + const counting = countingTransitions(model); + toSequence(counting.model, walk ? path : []); + + return counting.reads(); +}; + +describe("toSequence's derived fragments", () => { + it("reads each transition a fixed number of times when many branches share one tail", () => { + expect(sequenceReads(sharedTail, 2 * size, true)).toBeLessThan( + growthLimit * sequenceReads(sharedTail, size, true), + ); + }); + + it("reads each transition a fixed number of times for a decision with many ways round, on a path or none", () => { + for (const walk of [true, false]) { + expect(sequenceReads(manyLoops, 2 * size, walk)).toBeLessThan( + growthLimit * sequenceReads(manyLoops, size, walk), + ); + } + }); + + it("draws one loop round the decision and the alt for the way out", () => { + const { model, path } = manyLoops(size); + const message = (transition: string, label: string) => ({ + kind: "message" as const, + message: { transition, from: "shop", to: "shop", label }, + }); + + expect(toSequence(model, path).items).toEqual([ + { + kind: "fragment", + element: "d", + operator: "loop", + operands: [ + { + guard: "again", + items: [ + message("t-in", "D"), + message("t-l-0", "l-0"), + message("t-l-0-back", "D"), + ], + }, + ], + }, + { + kind: "fragment", + element: "d", + operator: "alt", + operands: [{ guard: "done", items: [message("t-x", "X")] }], + }, + ]); + }); +}); + +/** Message elements in a line between activities: `sources` activities lead into the first message, and the last message leads to `ends` activities. */ +const messageRun = ( + length: number, + sources: number, + ends: number, +): Model => { + const run = ids(length, "m-"); + const starts = ids(sources, "s-"); + const finishes = ids(ends, "e-"); + + return { + ...empty, + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + elements: [ + ...starts.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "customer", + })), + ...run.map((id) => ({ + kind: "message" as const, + id, + label: id, + from: "customer", + to: "shop", + mode: "async" as const, + })), + ...finishes.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "shop", + })), + ], + transitions: [ + ...starts.map((id) => ({ id: `t-${id}`, source: id, target: "m-0" })), + ...run.slice(1).map((id, index) => ({ + id: `t-${id}`, + source: run[index] ?? id, + target: id, + })), + ...finishes.map((id) => ({ + id: `t-${id}`, + source: `m-${String(length - 1)}`, + target: id, + })), + ], + }; +}; + +/** Transition reads every chain-drawing projection makes over a model. */ +const chainReads = (model: Model): number => { + const counting = countingTransitions(model); + toFlowchart(counting.model); + toSwimlane(counting.model); + handoffs(counting.model); + toStateMachine(counting.model, "order"); + + return counting.reads(); +}; + +describe("chains through runs of message elements", () => { + it("does not copy a long run of messages once per end it fans out to", () => { + expect(chainReads(messageRun(2 * size, 1, 2 * size))).toBeLessThan( + growthLimit * chainReads(messageRun(size, 1, size)), + ); + }); + + it("does not walk a shared run of messages once per source leading into it", () => { + expect(chainReads(messageRun(2 * size, 2 * size, 1))).toBeLessThan( + growthLimit * chainReads(messageRun(size, size, 1)), + ); + }); + + it("still lists a chain's whole run when it is read", () => { + const model = messageRun(size, 1, 1); + const [edge] = toFlowchart(model).edges; + + expect(edge?.through).toEqual(ids(size, "m-")); + expect(edge?.transitions).toHaveLength(size + 1); + expect(edge?.label).toBe(ids(size, "m-").join(", ")); + }); +}); + +/** Decisions in a line, each either doing its own step and going on to the next decision or skipping straight to the one shared end, and the path that does every step. Every decision's branches meet only at that end, so each draws an `opt` open until the path's last step. */ +const nestedSkips = ( + count: number, +): { readonly model: Model; readonly path: string[] } => { + const decisions = ids(count, "d-"); + const steps = ids(count, "a-"); + const model: Model = { + ...empty, + participants: [{ id: "shop", label: "Shop", kind: "system" }], + elements: [ + { kind: "activity", id: "start", label: "Start", actor: "shop" }, + ...decisions.flatMap((id, index) => [ + { kind: "decision" as const, id, label: id, actor: "shop" }, + { + kind: "activity" as const, + id: steps[index] ?? id, + label: steps[index] ?? id, + actor: "shop", + }, + ]), + { kind: "activity", id: "end", label: "End", actor: "shop" }, + ], + transitions: [ + { id: "t-start", source: "start", target: "d-0" }, + ...decisions.flatMap((id, index) => [ + { + id: `t-do-${id}`, + source: id, + target: `a-${String(index)}`, + guard: "do", + }, + { id: `t-skip-${id}`, source: id, target: "end", guard: "skip" }, + { + id: `t-on-${id}`, + source: `a-${String(index)}`, + target: decisions[index + 1] ?? "end", + }, + ]), + ], + }; + + return { + model, + path: [ + "t-start", + ...decisions.flatMap((id) => [`t-do-${id}`, `t-on-${id}`]), + ], + }; +}; + +const positionWork = (count: number): number => { + const { model, path } = nestedSkips(count); + let work = 0; + sequenceObserved(model, path, (positions) => { + work += positions; + }); + + return work; +}; + +describe("nested derived fragments", () => { + it("never expands a span into its positions, however deeply opts nest", () => { + expect(positionWork(2 * size)).toBeLessThanOrEqual(2 * positionWork(size)); + }); + + it("nests one opt per decision, each inside the one before", () => { + const { model, path } = nestedSkips(2); + + expect(sequenceObserved(model, path, undefined).items).toEqual([ + { + kind: "message", + message: { + transition: "t-start", + from: "shop", + to: "shop", + label: "d-0", + }, + }, + { + kind: "fragment", + element: "d-0", + operator: "opt", + operands: [ + { + guard: "do", + items: [ + { + kind: "message", + message: { + transition: "t-do-d-0", + from: "shop", + to: "shop", + label: "a-0", + }, + }, + { + kind: "message", + message: { + transition: "t-on-d-0", + from: "shop", + to: "shop", + label: "d-1", + }, + }, + { + kind: "fragment", + element: "d-1", + operator: "opt", + operands: [ + { + guard: "do", + items: [ + { + kind: "message", + message: { + transition: "t-do-d-1", + from: "shop", + to: "shop", + label: "a-1", + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + kind: "message", + message: { + transition: "t-on-d-1", + from: "shop", + to: "shop", + label: "End", + }, + }, + ]); + }); +}); + +/** + * Every shape a review has found quadratic work in, at a size and its double, through every entry point that reads it. The work, counted by `workOf` over the collection operations the projections are built from, is measured per item of input and output: work linear in what goes in and comes out keeps that ratio level as the shape doubles, while work quadratic in either doubles it. Some outputs are themselves larger than their model, such as an edge's lists and label when many edges share a long route; measuring against the output keeps those honest without hiding quadratic work behind a linear output. The bound sits midway, at `growthLimit` over `doubled`, because linear work with a fixed saving drifts slightly upwards. A newly found quadratic shape is a new row. + */ +const withEntity = ({ + model, + path, +}: { + readonly model: Model; + readonly path: readonly string[]; +}): Shape => ({ + model: { ...model, participants: actors }, + path, +}); + +/** A shape's name, how to build it at a size, and, where the default is too small for its quadratic part to show above the linear work around it, its own size. */ +const shapes: readonly ( + | readonly [string, (count: number) => Shape] + | readonly [string, (count: number) => Shape, number] +)[] = [ + ["a comb of messages", combShape], + ["a diamond of combs", diamondShape], + ["decisions nested to one meet", (count) => withEntity(nestedSkips(count))], + ["many sources into one run", sourcesIntoRunShape], + [ + "a run fanning out to many ends", + (count) => ({ + model: { ...messageRun(count, 1, count), participants: actors }, + path: [], + }), + ], + [ + "many sources into one shared run", + (count) => ({ + model: { ...messageRun(count, count, 1), participants: actors }, + path: [], + }), + ], + ["repeated steps under many authored fragments", authoredRepeatsShape], + ["a wide fork", wideForkShape], + ["serial diamonds of messages", serialDiamondsShape], + // Each path into the end once read the whole list of steps arriving at it; that list grows with the paths, but only overtakes the per-path work at the larger size. + ["many paths converging on one end", sharedEndShape, size], + // Every edge into the activities is a merged chain over the same two branches; reading their lists once walked those branches again per edge, which shows at the larger size. + ["two branches leading to many common ends", twoBranchesShape, size], + ["many sources through one message to many ends", fanThroughShape(1)], + ["many sources through two messages to many ends", fanThroughShape(2)], + // Every diamond's entrance also leads into a shared run; walks once entered that run from every start whether or not it led anywhere. + ["diamonds sharing a run that leads nowhere", sharedSuffixShape(false), size], + [ + "diamonds sharing a run half of which leads somewhere", + sharedSuffixShape(true), + size, + ], + [ + "an authored fragment over the path with one inside it per step", + authoredNestingShape, + ], + ["branches sharing one tail", (count) => withEntity(sharedTail(count))], + ["a decision with many ways round", (count) => withEntity(manyLoops(count))], +]; + +/** Each entry point, returning how many items its result holds, so work can be measured against what it had to produce. */ +const entries: readonly (readonly [string, (shape: Shape) => number])[] = [ + [ + "toFlowchart", + ({ model }) => { + const flow = toFlowchart(model); + + return flow.nodes.length + flow.edges.length; + }, + ], + [ + "toFlowchart, reading every edge's lists and label", + ({ model }) => { + const flow = toFlowchart(model); + let read = flow.nodes.length; + for (const edge of flow.edges) { + read += + 1 + + edge.transitions.length + + (edge.through?.length ?? 0) + + (edge.label?.length ?? 0); + } + + return read; + }, + ], + [ + "toSwimlane", + ({ model }) => { + const lanes = toSwimlane(model); + + return ( + lanes.nodes.length + + lanes.edges.length + + lanes.lanes.length + + lanes.handoffs.length + ); + }, + ], + ["handoffs", ({ model }) => handoffs(model).length], + [ + "toStateMachine", + ({ model }) => { + const machine = toStateMachine(model, "order"); + + return machine.states.length + machine.transitions.length; + }, + ], + [ + "toSequence", + ({ model, path }) => { + const diagram = toSequence(model, path); + + return diagram.messages.length + diagram.items.length; + }, + ], +]; + +/** The size of a shape as input: its elements, transitions, fragment bodies and path. */ +const inputSize = ({ model, path }: Shape): number => + model.elements.length + + model.transitions.length + + model.fragments.reduce( + (total, fragment) => + total + + fragment.operands.reduce((sum, operand) => sum + operand.body.length, 0), + 0, + ) + + path.length; + +/** Work per item of input and output for one shape through one entry point. */ +const workPerItem = (shape: Shape, enter: (shape: Shape) => number): number => { + let produced = 0; + const work = workOf(() => { + produced = enter(shape); + }); + + return work / (inputSize(shape) + produced); +}; + +/** The edit-list shapes, measured through the inverses that read them. */ +/** An edit to measure: the call, and how many items its input holds (the model and the edited view as read), so work is measured per item as for the projections. */ +interface Edit { + readonly run: () => unknown; + readonly input: number; +} + +/** Items in a model: its elements and transitions. */ +const modelSize = (model: Model): number => + model.elements.length + model.transitions.length; + +/** Items in a flowchart as an inverse reads it: its nodes, and each edge with its lists and label. */ +const viewSize = (flow: ReturnType>): number => + flow.nodes.length + + flow.edges.reduce( + (total, edge) => + total + + 1 + + edge.transitions.length + + (edge.through?.length ?? 0) + + (edge.label?.length ?? 0), + 0, + ); + +const edits: readonly (readonly [string, (count: number) => Edit])[] = [ + [ + "diffFlowchart adding a ring of bars", + (count) => { + const bars = ids(count, "bar-"); + const edited = { + nodes: bars.map((id) => ({ + id, + label: "Fork", + shape: "bar" as const, + })), + edges: bars.map((id, index) => ({ + from: id, + to: bars[(index + 1) % count] ?? id, + transitions: [`t-${id}`], + })), + }; + + return { + run: () => diffFlowchart(empty, edited), + input: edited.nodes.length + edited.edges.length, + }; + }, + ], + [ + "diffFlowchart deleting every edge of sources whose messages share one end", + (count) => { + const { model } = sharedEndShape(count); + const { nodes } = toFlowchart(model); + + return { + run: () => diffFlowchart(model, { nodes, edges: [] }), + input: modelSize(model), + }; + }, + ], + [ + "diffStateMachine deleting every transition of sources whose messages share one end", + (count) => { + const { model } = sharedEndShape(count); + const { states } = toStateMachine(model, "order"); + + return { + run: () => + diffStateMachine(model, { + subject: "order", + states, + transitions: [], + }), + input: modelSize(model), + }; + }, + ], + [ + "diffFlowchart reading an unchanged flowchart of merged edges over two branches", + (count) => { + const { model } = twoBranchesShape(count); + const flow = toFlowchart(model); + + return { + run: () => diffFlowchart(model, flow), + input: modelSize(model) + viewSize(flow), + }; + }, + ], + ...[false, true].map( + (livePart): readonly [string, (count: number) => Edit] => [ + `diffFlowchart reading an unchanged flowchart of diamonds sharing a run ${livePart ? "half of which leads somewhere" : "that leads nowhere"}`, + (count) => { + const { model } = sharedSuffixShape(livePart)(count); + const flow = toFlowchart(model); + + return { + run: () => diffFlowchart(model, flow), + input: modelSize(model) + viewSize(flow), + }; + }, + ], + ), + [ + "applyModelDiff renaming every activity", + (count) => { + const activities = ids(count, "a-"); + const model: Model = { + ...empty, + elements: activities.map((id) => ({ + kind: "activity", + id, + label: id, + })), + }; + const diff: ModelDiff = { + elements: activities.map((id) => ({ + kind: "renamed", + element: id, + from: id, + to: `${id}!`, + inferred: false, + })), + transitions: [], + events: [], + }; + + return { + run: () => applyModelDiff(model, diff), + input: modelSize(model) + diff.elements.length, + }; + }, + ], +]; + +const doubled = 2; +/** Each projected shape's smaller size in the table: large enough that quadratic work doubles per item while linear work stays level, small enough that a shape whose output is itself quadratic stays quick to build with every collection call counted. */ +const tableSize = 100; + +describe("work at most doubles when a reported shape doubles", () => { + describe.each(shapes)("%s", (_shape, build, own = tableSize) => { + const small = build(own); + const large = build(doubled * own); + + it.each(entries)("through %s", (_entry, enter) => { + expect(workPerItem(large, enter)).toBeLessThan( + (growthLimit / doubled) * workPerItem(small, enter), + ); + }); + }); + + it.each(edits)("%s", (_edit, prepare) => { + // Edits re-project the model before reading the edit, a fixed cost per item that a rescan per edited item only overtakes at the larger size. + const small = prepare(size); + const large = prepare(doubled * size); + + expect(workOf(large.run) / large.input).toBeLessThan( + (growthLimit / doubled) * (workOf(small.run) / small.input), + ); + }); +}); + +describe("folding never multiplies edges", () => { + it.each([1, 2])( + "draws no more edges or hand-offs than the model has transitions when many sources pass %i message(s) to many ends", + (through) => { + for (const count of [size, doubled * size]) { + const { model } = fanThroughShape(through)(count); + const bound = model.transitions.length; + + expect(toFlowchart(model).edges.length).toBeLessThanOrEqual(bound); + expect(handoffs(model).length).toBeLessThanOrEqual(bound); + expect( + toStateMachine(model, "order").transitions.length, + ).toBeLessThanOrEqual(bound); + } + }, + ); +}); diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts new file mode 100644 index 0000000..36145bc --- /dev/null +++ b/packages/core/graph/projection/src/chains.ts @@ -0,0 +1,854 @@ +import { + buildDag, + stronglyConnectedComponents, +} from "@exadev/process-graph-dag"; +import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; + +export type TransitionOf = Transition; + +/** The transitions leaving each element, in model order. */ +export const outgoingMap = ( + model: Model, +): ReadonlyMap[]> => { + const outgoing = new Map[]>(); + for (const transition of model.transitions) { + const list = outgoing.get(transition.source); + if (list === undefined) { + outgoing.set(transition.source, [transition]); + } else { + list.push(transition); + } + } + + return outgoing; +}; + +/** A value found on a route, with how deep in the route forest it was found and whether it came from the folded element itself (after the transition arriving at it) or from that transition. */ +interface Found { + readonly value: V; + readonly depth: number; + readonly atElement: boolean; +} + +/** A guard on a route, linked to the guards before it; shared between every route through the same elements. */ +interface GuardLink { + readonly guard: string; + readonly depth: number; + readonly previous: GuardLink | undefined; +} + +/** + * Everything that leads from one transition to one drawn element through folded elements. Several routes from the same first transition to the same target are one chain. The lists are built only when read, so a projection that draws many chains sharing a long run of folded elements does not copy that run once per chain. + */ +export interface Chain { + readonly first: TransitionOf; + readonly target: I["element"]; + /** Whether more than one route may lead to `target`: some folded element on the way, or `target` itself, is entered from more than one folded element, or the way passes a cycle of folded elements. */ + readonly merged: boolean; + /** Every transition on some route to `target`, each once, in the order a depth-first walk in model order first meets them; the first transition comes first. Built when called. */ + readonly transitions: () => readonly TransitionOf[]; + /** Every folded element on some route to `target`, each once, in the same order. Empty when `first` arrives at `target` directly. Built when called. */ + readonly through: () => readonly I["element"][]; + /** On a single route, its guards in order; on merged routes, none, since they belong to different routes. Built when called. */ + readonly guards: () => readonly string[]; + /** Whether `guards` is non-empty, known without building it. */ + readonly guarded: boolean; + /** On a single route, the last trigger along it, counting each folded element's own event right after the transition arriving at it; `undefined` on merged routes. */ + readonly trigger: I["event"] | undefined; + /** On a single route, the last effect along it; `undefined` on merged routes. */ + readonly effect: I["action"] | undefined; +} + +/** A component's reachable targets, stored in reverse of the order they are read in, so a reader is extended by pushing; `members` is kept only while some later component may still take the array over. */ +interface Targets { + readonly reversed: T[]; + members: Set | undefined; + /** Whether a start reads this array or more than one component builds on it, so it must never be extended in place. */ + pinned: boolean; +} + +/** One part of what a component's targets are made of, in reading order: targets of a component it holds itself, or the array of a component built on its own. */ +type Piece = + | { readonly own: readonly T[]; readonly component?: never } + | { readonly component: number; readonly own?: never }; + +/** What a notation folds away and what it draws. */ +export interface Folding { + /** Whether the notation folds an element away instead of drawing it. Asked once per element. */ + readonly folded: (element: I["element"]) => boolean; + /** Whether a chain may end at an element the notation does not fold. */ + readonly accepted: (element: I["element"]) => boolean; + /** The event a folded element contributes to a route, such as a message element's; asked once per folded element. */ + readonly eventOf?: (element: I["element"]) => I["event"] | undefined; + /** Told how many references the index stores whenever it stores target structure, so a caller can measure its size. */ + readonly observe?: (references: number) => void; +} + +/** Chains through one model's folded elements, built once and shared by every chain that starts with a transition of that model. */ +export interface ChainIndex { + /** The chains that start with `first`, one per accepted element it leads to: first the ones its own folded element leaves for directly that nothing further on reaches, then those further on, a target reached by several ways on coming with the last of them. */ + readonly from: (first: TransitionOf) => readonly Chain[]; + /** The transitions arriving at `element` from folded elements. */ + readonly arriving: (element: I["element"]) => readonly TransitionOf[]; + /** The folded elements on some chain that starts with one of `firsts`. */ + readonly carried: ( + firsts: Readonly>>, + ) => ReadonlySet; + /** The elements the notation would fold but does not, because folding them would multiply edges; the notation draws them no more than it draws any element it has no shape for. */ + readonly unfolded: ReadonlySet; +} + +/** Appends `value` to the list `map` holds under `key`. */ +const append = ( + map: Map, + key: Key, + value: Value, +): void => { + const list = map.get(key); + if (list === undefined) { + map.set(key, [value]); + } else { + list.push(value); + } +}; + +/** A computation run once, on first call, and remembered. */ +export const once = (compute: () => T): (() => T) => { + let memo: { readonly value: T } | undefined; + + return () => { + memo ??= { value: compute() }; + + return memo.value; + }; +}; + +/** More than this many distinct entrances or targets is counted as "many": the folding rule only needs to tell one, two and more apart. */ +const enough = 3; + +/** Adds `values` to a capped set: once it would hold `enough`, it stands for "many" and keeps no more. */ +const addCapped = (set: Set, values: Readonly>): void => { + for (const value of values) { + if (set.size >= enough) { + return; + } + set.add(value); + } +}; + +/** + * The elements a notation would fold that it must not, because folding them would multiply edges. + * + * Folding an element replaces the routes through it with one chain per pair of a way in and a drawn element it leads to. Through a folded region, an element is reached by some set of entering transitions (transitions from an element that is not folded) and leads to some set of accepted targets; folding it can draw up to entering times targets chains where the model had entering plus targets transitions. So an element is folded only when that product is no more than that sum: when one side is a single item, or both are two. Counting k sources into one message that leads to k ends, or into a run of messages that fans out to k ends further on, both fail, so neither draws k times k edges. The counts are the distinct entering transitions reaching each element and the distinct targets it reaches, both through folded elements, each held as a set capped at `enough` and carried over the condensed folded graph once in each direction, so deciding every element costs linear work. The counts are taken before any element is unfolded, so an element downstream of an unfolded one may be unfolded too; that errs towards drawing less, never towards multiplying. + */ +const multiplying = ( + model: Model, + outgoing: ReadonlyMap[]>, + foldable: ReadonlySet, + accepted: (element: I["element"]) => boolean, +): ReadonlySet => { + const components = stronglyConnectedComponents( + buildDag([...foldable], (id) => + (outgoing.get(id) ?? []) + .map((transition) => transition.target) + .filter((target) => foldable.has(target)), + ), + ); + const componentOf = new Map(); + for (const [index, members] of components.entries()) { + for (const member of members) { + componentOf.set(member, index); + } + } + const entering = components.map(() => new Set()); + const reaching = components.map(() => new Set()); + const before = components.map((): number[] => []); + const after = components.map((): number[] => []); + for (const transition of model.transitions) { + const from = componentOf.get(transition.source); + const to = componentOf.get(transition.target); + if (to !== undefined && from === undefined) { + addCapped(entering[to] ?? new Set(), [transition.id]); + } + if (from !== undefined && to === undefined && accepted(transition.target)) { + addCapped(reaching[from] ?? new Set(), [transition.target]); + } + if (from !== undefined && to !== undefined && from !== to) { + before[to]?.push(from); + after[from]?.push(to); + } + } + // Components come out with each before the components that need it along the folded transitions: successors first. Targets flow back from successors in that order; entrances flow forward in the reverse. + for (const [index] of components.entries()) { + for (const successor of after[index] ?? []) { + addCapped(reaching[index] ?? new Set(), reaching[successor] ?? []); + } + } + for (let index = components.length - 1; index >= 0; index -= 1) { + for (const predecessor of before[index] ?? []) { + addCapped(entering[index] ?? new Set(), entering[predecessor] ?? []); + } + } + const unfolded = new Set(); + for (const [index, members] of components.entries()) { + const ways = entering[index]?.size ?? 0; + const ends = reaching[index]?.size ?? 0; + if (ways * ends > ways + ends) { + for (const member of members) { + unfolded.add(member); + } + } + } + + return unfolded; +}; + +/** + * Indexes a model's folded elements so chains can be read off for any first transition. + * + * An element the notation would fold is left unfolded when folding it would multiply edges (see `multiplying`), and is then drawn no more than any element the notation has no shape for; `unfolded` lists them so the projection can report them. + * + * The folded elements that lead to some accepted element are found first, by one walk back from the accepted elements, and no walk enters any other, so a run that leads nowhere costs nothing. The folded elements are condensed into strongly connected components in one pass, and the condensed graph is read in reverse topological order. A component gets a flat array of its accepted targets only when something reads it on its own: it is entered from an element that is not folded, or from two or more components. Every other component has at most one component before it and is read as part of that one, so it is visited once in all. Arrays are built on demand and remembered, so a region no start reaches costs nothing; a generation stamp per target removes duplicates in constant time, a component with nothing of its own to add shares the one array it reads, and the only builder of an array nothing else reads extends it in place instead of copying it. The total work is linear in the elements and transitions plus the sizes of the arrays that must be copied, which is where two or more starts, or two or more components, read the same targets. Folded elements entered from exactly one folded element, outside any cycle, form a forest of routes; each records how deep it is and the last trigger, effect and guard on the way down to it, so a single-route chain's labels are found from its ends in constant time. + * + * Merged chains' lists, and every chain's lists, are built only when read. + */ +export const chainIndex = ( + model: Model, + folding: Folding, +): ChainIndex => { + const outgoing = outgoingMap(model); + const foldable = new Set(); + for (const element of model.elements) { + if (folding.folded(element.id)) { + foldable.add(element.id); + } + } + const unfolded = multiplying(model, outgoing, foldable, folding.accepted); + const isFolded = new Set( + [...foldable].filter((id) => !unfolded.has(id)), + ); + const events = new Map(); + for (const id of isFolded) { + const event = folding.eventOf?.(id); + if (event !== undefined) { + events.set(id, event); + } + } + + const foldedIn = new Map[]>(); + const arrivingIn = new Map[]>(); + for (const transition of model.transitions) { + if (!isFolded.has(transition.source)) { + continue; + } + append( + isFolded.has(transition.target) ? foldedIn : arrivingIn, + transition.target, + transition, + ); + } + + // The folded elements that lead to some accepted element, found once by walking back from the accepted elements over the transitions into them. No walk enters any other folded element, so a suffix that leads nowhere costs nothing however many chains pass its entrance. + const live = new Set(); + const towards: I["element"][] = []; + for (const [target, into] of arrivingIn) { + if (!folding.accepted(target)) { + continue; + } + for (const { source } of into) { + if (!live.has(source)) { + live.add(source); + towards.push(source); + } + } + } + for (let at = towards.pop(); at !== undefined; at = towards.pop()) { + for (const { source } of foldedIn.get(at) ?? []) { + if (!live.has(source)) { + live.add(source); + towards.push(source); + } + } + } + /** Whether a walk follows a transition: into a folded element that leads somewhere, or into an accepted element. */ + const followed = (target: I["element"]): boolean => + isFolded.has(target) ? live.has(target) : folding.accepted(target); + + const foldedIds = [...isFolded]; + const components = stronglyConnectedComponents( + buildDag(foldedIds, (id) => + (outgoing.get(id) ?? []) + .map((transition) => transition.target) + .filter((target) => isFolded.has(target)), + ), + ); + const componentOf = new Map(); + for (const [index, members] of components.entries()) { + for (const member of members) { + componentOf.set(member, index); + } + } + const cyclic = new Set(); + for (const members of components) { + const [only] = members; + const selfLoop = + only !== undefined && + (outgoing.get(only) ?? []).some( + (transition) => transition.target === only, + ); + if (members.length > 1 || selfLoop) { + for (const member of members) { + cyclic.add(member); + } + } + } + + const observe = folding.observe; + const successorsOf: number[][] = components.map(() => []); + const predecessors = components.map(() => 0); + for (const [index, members] of components.entries()) { + const seen = new Set(); + for (const member of members) { + for (const { target } of outgoing.get(member) ?? []) { + const successor = componentOf.get(target); + if ( + successor !== undefined && + successor !== index && + !seen.has(successor) + ) { + seen.add(successor); + successorsOf[index]?.push(successor); + predecessors[successor] = (predecessors[successor] ?? 0) + 1; + } + } + } + } + // Each component's targets are a flat array, built only for the components something reads on its own: a start (entered from an element that is not folded) or a component entered from two or more components. Every other component has at most one component before it and is read as part of that one, so it is visited once in all. Arrays are built on demand and remembered, so a region no start reaches costs nothing. + const enteredFromOutside = new Set(); + for (const transition of model.transitions) { + const component = componentOf.get(transition.target); + if (component !== undefined && !isFolded.has(transition.source)) { + enteredFromOutside.add(component); + } + } + const standalone = (component: number): boolean => + enteredFromOutside.has(component) || (predecessors[component] ?? 0) > 1; + const ownOf = (component: number): I["element"][] => { + const own: I["element"][] = []; + for (const member of components[component] ?? []) { + for (const { target } of outgoing.get(member) ?? []) { + if (!isFolded.has(target) && folding.accepted(target)) { + own.push(target); + } + } + } + + return own; + }; + /** What a standalone component's targets are made of, in reading order: its own targets, then each way on's in order, reading through components that stand only on it and stopping at the next standalone ones. */ + const piecesOf = (component: number): Piece[] => { + const found: Piece[] = []; + // Visit a component standing on the one before it, or record a standalone one met at this point in reading order. + const stack: { readonly component: number; readonly visit: boolean }[] = [ + { component, visit: true }, + ]; + for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + if (!next.visit) { + observe?.(1); + found.push({ component: next.component }); + continue; + } + const own = ownOf(next.component); + if (own.length > 0) { + observe?.(own.length); + found.push({ own }); + } + for (const successor of ( + successorsOf[next.component] ?? [] + ).toReversed()) { + stack.push({ component: successor, visit: !standalone(successor) }); + } + } + + return found; + }; + const piecesFor = new Map[]>(); + const pieces = (component: number): Piece[] => { + const known = piecesFor.get(component); + if (known !== undefined) { + return known; + } + const found = piecesOf(component); + piecesFor.set(component, found); + + return found; + }; + // How many standalone components build directly on each standalone one, counted once each, so an array read by one builder alone can be extended in place by it. + const builders = new Map(); + for (const [index] of components.entries()) { + if (!standalone(index)) { + continue; + } + const readFrom = new Set( + pieces(index).flatMap((piece) => + piece.component === undefined ? [] : [piece.component], + ), + ); + for (const read of readFrom) { + builders.set(read, (builders.get(read) ?? 0) + 1); + } + } + const pinnedFor = (component: number): boolean => + enteredFromOutside.has(component) || (builders.get(component) ?? 0) > 1; + + const arrays = new Map>(); + // A generation stamp per target for removing duplicates while one array is built: a target is already in the array when its stamp is the current generation. + const stamp = new Map(); + let generation = 0; + const build = (component: number): Targets => { + const parts = pieces(component); + const only = parts.length === 1 ? parts[0] : undefined; + const onlyArray = + only?.component === undefined ? undefined : arrays.get(only.component); + if (onlyArray !== undefined) { + // Nothing of its own to add to the one array it reads: share it. + onlyArray.pinned ||= pinnedFor(component); + + return onlyArray; + } + // Reading order keeps each target at its last place: the own targets nothing further on reaches come first. Built backwards, that is each target's first place, so pieces are taken last to first and each piece from its end. + const last = parts.at(-1); + const lastArray = + last?.component === undefined ? undefined : arrays.get(last.component); + const takeOver = + lastArray !== undefined && + !lastArray.pinned && + lastArray.members !== undefined; + const target: Targets = takeOver + ? lastArray + : { reversed: [], members: undefined, pinned: pinnedFor(component) }; + target.pinned = pinnedFor(component); + generation += 1; + const members = target.members; + const present = (value: I["element"]): boolean => + members === undefined + ? stamp.get(value) === generation + : members.has(value); + const add = (value: I["element"]): void => { + if (present(value)) { + return; + } + observe?.(1); + target.reversed.push(value); + if (members === undefined) { + stamp.set(value, generation); + } else { + members.add(value); + } + }; + for ( + let index = parts.length - (takeOver ? 2 : 1); + index >= 0; + index -= 1 + ) { + const piece = parts[index]; + if (piece === undefined) { + continue; + } + if (piece.own !== undefined) { + for (const value of piece.own.toReversed()) { + add(value); + } + continue; + } + for (const value of arrays.get(piece.component)?.reversed ?? []) { + add(value); + } + } + // An array nobody else may read keeps a set of its members, so the one component that builds on it can take it over without rescanning it. + if (target.pinned) { + target.members = undefined; + } else if (target.members === undefined) { + observe?.(target.reversed.length); + target.members = new Set(target.reversed); + } + + return target; + }; + const arrayOf = (component: number): Targets => { + const frames: { readonly component: number; expanded: boolean }[] = [ + { component, expanded: false }, + ]; + for ( + let frame = frames.at(-1); + frame !== undefined; + frame = frames.at(-1) + ) { + if (arrays.has(frame.component)) { + frames.pop(); + continue; + } + if (!frame.expanded) { + frame.expanded = true; + for (const piece of pieces(frame.component)) { + if (piece.component !== undefined && !arrays.has(piece.component)) { + frames.push({ component: piece.component, expanded: false }); + } + } + continue; + } + arrays.set(frame.component, build(frame.component)); + frames.pop(); + } + const found = arrays.get(component); + if (found === undefined) { + throw new Error( + "Invariant violated: a component's targets were not built", + ); + } + + return found; + }; + const leads: boolean[] = []; + for (const [index] of components.entries()) { + leads[index] = + ownOf(index).length > 0 || + (successorsOf[index] ?? []).some( + (successor) => leads[successor] === true, + ); + } + /** A start component's targets in reading order; only ever asked of a component entered from outside, which is standalone. */ + const targetsOf = (component: number): readonly I["element"][] => + arrayOf(component).reversed.toReversed(); + + // The route forest: a folded element outside every cycle entered from exactly one folded element that is also outside every cycle hangs below it. + const parentLink = new Map>(); + const children = new Map(); + for (const id of foldedIds) { + const into = foldedIn.get(id); + const link = into?.length === 1 ? into[0] : undefined; + if (link !== undefined && !cyclic.has(id) && !cyclic.has(link.source)) { + parentLink.set(id, link); + append(children, link.source, id); + } + } + const depth = new Map(); + const enter = new Map(); + const leave = new Map(); + const lastTrigger = new Map>(); + const lastEffect = new Map>(); + const guardsTo = new Map(); + let clock = 0; + for (const root of foldedIds) { + if (parentLink.has(root)) { + continue; + } + const stack: { readonly id: I["element"]; next: number }[] = [ + { id: root, next: 0 }, + ]; + const visit = (id: I["element"], level: number): void => { + depth.set(id, level); + enter.set(id, clock); + clock += 1; + const link = parentLink.get(id); + const parent = link?.source; + const event = events.get(id); + const trigger = + event !== undefined + ? { value: event, depth: level, atElement: true } + : link?.trigger !== undefined + ? { value: link.trigger, depth: level, atElement: false } + : parent === undefined + ? undefined + : lastTrigger.get(parent); + if (trigger !== undefined) { + lastTrigger.set(id, trigger); + } + const effect = + link?.effect !== undefined + ? { value: link.effect, depth: level, atElement: false } + : parent === undefined + ? undefined + : lastEffect.get(parent); + if (effect !== undefined) { + lastEffect.set(id, effect); + } + const above = parent === undefined ? undefined : guardsTo.get(parent); + const guards = + link?.guard !== undefined + ? { guard: link.guard, depth: level, previous: above } + : above; + if (guards !== undefined) { + guardsTo.set(id, guards); + } + }; + visit(root, 0); + for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) { + const child = (children.get(top.id) ?? [])[top.next]; + if (child === undefined) { + leave.set(top.id, clock); + stack.pop(); + continue; + } + top.next += 1; + visit(child, (depth.get(top.id) ?? 0) + 1); + stack.push({ id: child, next: 0 }); + } + } + /** Whether `ancestor` is `node` or above it in the route forest. */ + const above = (ancestor: I["element"], node: I["element"]): boolean => { + const start = enter.get(ancestor); + const stop = leave.get(ancestor); + const at = enter.get(node); + + return ( + start !== undefined && + stop !== undefined && + at !== undefined && + start <= at && + at < stop + ); + }; + + /** What a depth-first walk from a component meets, among the transitions it follows: each transition and each folded element entered, numbered in the order met, and the transitions met indexed by the element they arrive at. Done once per component, from its first member in model order, however many chains start in it or end at its targets; every member of a component reaches the same elements, so they share it. */ + interface Walk { + readonly order: ReadonlyMap, number>; + readonly entered: ReadonlyMap; + readonly into: ReadonlyMap[]>; + } + const position = new Map( + foldedIds.map((id, index) => [id, index]), + ); + const rootOf = (component: number): I["element"] | undefined => { + let root: I["element"] | undefined; + for (const member of components[component] ?? []) { + if ( + root === undefined || + (position.get(member) ?? 0) < (position.get(root) ?? 0) + ) { + root = member; + } + } + + return root; + }; + const walks = new Map(); + const walkFrom = (element: I["element"]): Walk => { + const component = componentOf.get(element); + const known = component === undefined ? undefined : walks.get(component); + if (known !== undefined) { + return known; + } + const start = + (component === undefined ? undefined : rootOf(component)) ?? element; + const order = new Map, number>(); + const entered = new Map([[start, 0]]); + const into = new Map[]>(); + const stack: { readonly id: I["element"]; next: number }[] = [ + { id: start, next: 0 }, + ]; + for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) { + const transition = (outgoing.get(top.id) ?? [])[top.next]; + if (transition === undefined) { + stack.pop(); + continue; + } + top.next += 1; + if (!followed(transition.target)) { + continue; + } + order.set(transition, order.size); + append(into, transition.target, transition); + if (isFolded.has(transition.target) && !entered.has(transition.target)) { + entered.set(transition.target, entered.size); + stack.push({ id: transition.target, next: 0 }); + } + } + const walk = { order, entered, into }; + if (component !== undefined) { + walks.set(component, walk); + } + + return walk; + }; + + /** The union of every route from `first` to `target`: walking back from `target` over the shared walk's index of what arrives where, so the work is the size of this union, then put in the order the walk met each transition and element. */ + const union = ( + first: TransitionOf, + target: I["element"], + ): { + readonly transitions: readonly TransitionOf[]; + readonly through: readonly I["element"][]; + } => { + const walk = walkFrom(first.target); + const leading = new Set([target]); + const back: I["element"][] = [target]; + const transitions: TransitionOf[] = []; + for (let at = back.pop(); at !== undefined; at = back.pop()) { + for (const transition of walk.into.get(at) ?? []) { + transitions.push(transition); + if (!leading.has(transition.source)) { + leading.add(transition.source); + back.push(transition.source); + } + } + } + const metAt = (transition: TransitionOf): number => + walk.order.get(transition) ?? 0; + const enteredAt = (element: I["element"]): number => + walk.entered.get(element) ?? 0; + + return { + transitions: [first, ...transitions.sort((a, b) => metAt(a) - metAt(b))], + through: [...leading] + .filter((element) => walk.entered.has(element)) + .sort((a, b) => enteredAt(a) - enteredAt(b)), + }; + }; + + const from = (first: TransitionOf): readonly Chain[] => { + const start = first.target; + if (!isFolded.has(start)) { + return folding.accepted(start) + ? [ + { + first, + target: start, + merged: false, + transitions: () => [first], + through: () => [], + guards: () => (first.guard === undefined ? [] : [first.guard]), + guarded: first.guard !== undefined, + trigger: first.trigger, + effect: first.effect, + }, + ] + : []; + } + const component = componentOf.get(start); + const reached = component === undefined ? [] : targetsOf(component); + const level = depth.get(start); + + return reached.map((target): Chain => { + // Only the length and the one arriving step are read, never a copy of the list: many chains end at the same target. + const into = arrivingIn.get(target); + const last = into?.length === 1 ? into[0] : undefined; + const source = last?.source; + const single = + last !== undefined && + source !== undefined && + level !== undefined && + above(start, source); + const lists = once(() => union(first, target)); + if (!single) { + return { + first, + target, + merged: true, + transitions: () => lists().transitions, + through: () => lists().through, + guards: () => [], + guarded: false, + trigger: undefined, + effect: undefined, + }; + } + // Along the route, what lies strictly below `start` counts, and at `start` only its own event: the transition into `start` is not on the route. + const onRoute = (found: Found | undefined): boolean => + found !== undefined && + (found.depth > level || (found.depth === level && found.atElement)); + const triggerFound = lastTrigger.get(source); + const effectFound = lastEffect.get(source); + const guardsFound = guardsTo.get(source); + const routeGuards = once(() => { + const middle: string[] = []; + for ( + let link = guardsFound; + link !== undefined && link.depth > level; + link = link.previous + ) { + middle.push(link.guard); + } + + return [ + ...(first.guard === undefined ? [] : [first.guard]), + ...middle.toReversed(), + ...(last.guard === undefined ? [] : [last.guard]), + ]; + }); + const route = once(() => { + const nodes: I["element"][] = []; + const links: TransitionOf[] = []; + for (let at: I["element"] | undefined = source; at !== undefined;) { + nodes.push(at); + if (at === start) { + break; + } + const link = parentLink.get(at); + if (link !== undefined) { + links.push(link); + } + at = link?.source; + } + + return { + through: nodes.toReversed(), + transitions: [first, ...links.toReversed(), last], + }; + }); + + return { + first, + target, + merged: false, + transitions: () => route().transitions, + through: () => route().through, + guards: routeGuards, + guarded: + first.guard !== undefined || + last.guard !== undefined || + (guardsFound !== undefined && guardsFound.depth > level), + trigger: + last.trigger ?? + (onRoute(triggerFound) ? triggerFound?.value : undefined) ?? + first.trigger, + effect: + last.effect ?? + (effectFound !== undefined && effectFound.depth > level + ? effectFound.value + : undefined) ?? + first.effect, + }; + }); + }; + + const carried = ( + firsts: Readonly>>, + ): ReadonlySet => { + const reached = new Set(); + const stack: I["element"][] = []; + for (const first of firsts) { + if (live.has(first.target) && !reached.has(first.target)) { + reached.add(first.target); + stack.push(first.target); + } + } + for (let at = stack.pop(); at !== undefined; at = stack.pop()) { + for (const transition of outgoing.get(at) ?? []) { + if (live.has(transition.target) && !reached.has(transition.target)) { + reached.add(transition.target); + stack.push(transition.target); + } + } + } + + return reached; + }; + + return { + from, + arriving: (element) => arrivingIn.get(element) ?? [], + carried, + unfolded, + }; +}; diff --git a/packages/core/graph/projection/src/chains.unit.test.ts b/packages/core/graph/projection/src/chains.unit.test.ts new file mode 100644 index 0000000..4d90ec2 --- /dev/null +++ b/packages/core/graph/projection/src/chains.unit.test.ts @@ -0,0 +1,365 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { chainIndex } from "./chains.ts"; +import { + diffFlowchart, + handoffs, + toFlowchart, + toStateMachine, +} from "./index.ts"; + +/** Enough teeth that a list copied per message would hold far more references than the comb has elements. */ +const combLength = 300; + +/** Message layers between the drawn ends; the routes from one end to the other number `width` to the power of this, so enumerating them is out of reach long before the model is large. */ +const layers = 8; +/** Message elements per layer, each joined to every one in the next layer. */ +const width = 3; + +const messageId = (layer: number, slot: number): string => + `m-${String(layer)}-${String(slot)}`; +const slots = Array.from({ length: width }, (_, slot) => slot); +const depths = Array.from({ length: layers }, (_, layer) => layer); +const sources = ["a-s1", "a-s2"]; +const ends = ["a-e1", "a-e2"]; + +/** + * Two customer steps, each entering the first message layer at its own message, then message layers each fully joined to the next, then two shop steps every last-layer message leads to. Every message is folded by every notation, so each drawn source reaches each drawn end through every route across the layers. + */ +const layered: Model = { + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [], + actions: [], + artefacts: [], + elements: [ + ...sources.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "customer", + })), + ...depths.flatMap((layer) => + slots.map((slot) => ({ + kind: "message" as const, + id: messageId(layer, slot), + label: messageId(layer, slot), + from: "customer", + to: "shop", + mode: "async" as const, + })), + ), + ...ends.map((id) => ({ + kind: "activity" as const, + id, + label: id, + actor: "shop", + })), + ], + transitions: [ + ...sources.map((source, slot) => ({ + id: `in-${source}`, + source, + target: messageId(0, slot), + })), + ...depths.slice(1).flatMap((layer) => + slots.flatMap((from) => + slots.map((to) => ({ + id: `t-${messageId(layer - 1, from)}-${String(to)}`, + source: messageId(layer - 1, from), + target: messageId(layer, to), + })), + ), + ), + ...slots.flatMap((slot) => + ends.map((end) => ({ + id: `out-${messageId(layers - 1, slot)}-${end}`, + source: messageId(layers - 1, slot), + target: end, + })), + ), + ], + fragments: [], + projections: [], +}; + +const pairs = sources.flatMap((source) => ends.map((end) => [source, end])); + +describe("chains through a layered acyclic graph of message elements", () => { + it("asks whether each element is folded once, and reaches every end from each source as one merged chain", () => { + const messages = new Set( + layered.elements.flatMap((element) => + element.kind === "message" ? [element.id] : [], + ), + ); + const visits = new Map(); + const index = chainIndex(layered, { + folded: (element) => { + visits.set(element, (visits.get(element) ?? 0) + 1); + + return messages.has(element); + }, + accepted: (element) => ends.includes(element), + }); + for (const first of layered.transitions.filter((transition) => + sources.includes(transition.source), + )) { + const chains = index.from(first); + + expect(chains.map((chain) => chain.target)).toEqual(ends); + expect(chains.every((chain) => chain.merged)).toBe(true); + } + expect([...visits.values()].every((count) => count === 1)).toBe(true); + expect(visits.size).toBe(layered.elements.length); + }); + + it("draws one flowchart edge per distinct drawn source and target, carrying every message on its routes", () => { + const flow = toFlowchart(layered); + + expect(flow.edges.map((edge) => [edge.from, edge.to])).toEqual(pairs); + const [edge] = flow.edges; + expect(new Set(edge?.through)).toEqual( + new Set([ + messageId(0, 0), + ...depths + .slice(1) + .flatMap((layer) => slots.map((slot) => messageId(layer, slot))), + ]), + ); + // Only the sources enter the first layer, so its last message is reached by no step. + expect(flow.loss).toEqual([ + { kind: "message-elements", elements: [messageId(0, width - 1)] }, + ]); + }); + + it("derives one hand-off and one state machine transition per distinct pair", () => { + expect( + handoffs(layered).map((handoff) => [handoff.transition, handoff.to]), + ).toEqual( + sources.flatMap((source) => ends.map(() => [`in-${source}`, "shop"])), + ); + expect( + toStateMachine(layered, "order").transitions.map((transition) => [ + transition.source, + transition.target, + ]), + ).toEqual(pairs); + }); + + it("removes only the steps arriving at its end when a merged edge is removed, as inferred changes", () => { + const flow = toFlowchart(layered); + const diff = diffFlowchart(layered, { + nodes: flow.nodes, + edges: flow.edges.filter( + (edge) => !(edge.from === "a-s1" && edge.to === "a-e1"), + ), + }); + + expect(diff.transitions).toEqual( + slots.map((slot) => ({ + kind: "removed", + transition: `out-${messageId(layers - 1, slot)}-a-e1`, + inferred: true, + })), + ); + }); +}); + +describe("a merged chain's labels", () => { + /** One step into a decision whose two guarded branches each pass through their own message and meet again at the next step. */ + const diamond: Model = { + participants: [{ id: "order", label: "Order", kind: "entity" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "decision", id: "d", label: "D" }, + ...["m-yes", "m-no"].map((id) => ({ + kind: "message" as const, + id, + label: id, + from: "x", + to: "y", + mode: "async" as const, + })), + { kind: "activity", id: "b", label: "B" }, + ], + transitions: [ + { id: "t1", source: "a", target: "d" }, + { id: "t2", source: "d", target: "m-yes", guard: "yes" }, + { id: "t3", source: "d", target: "m-no", guard: "no" }, + { id: "t4", source: "m-yes", target: "b" }, + { id: "t5", source: "m-no", target: "b" }, + ], + fragments: [], + projections: [], + }; + + it("draws the routes as one state machine transition carrying no guard, since its guards belong to different routes", () => { + expect(toStateMachine(diamond, "order").transitions).toEqual([ + { + source: "a", + target: "b", + transitions: ["t1", "t2", "t4", "t3", "t5"], + through: ["d", "m-yes", "m-no"], + }, + ]); + }); +}); + +/** Messages in a line, each with its own exit to an activity, entered from one activity: the reachable targets grow by one at every message. */ +const comb = ( + length: number, +): { readonly model: Model; readonly ends: string[] } => { + const teeth = Array.from({ length }, (_, index) => `m-${String(index)}`); + const exits = teeth.map((id) => `e-${id}`); + + return { + model: { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "s", label: "S" }, + ...teeth.map((id) => ({ + kind: "message" as const, + id, + label: id, + from: "x", + to: "y", + mode: "async" as const, + })), + ...exits.map((id) => ({ kind: "activity" as const, id, label: id })), + ], + transitions: [ + { id: "t-in", source: "s", target: "m-0" }, + ...teeth.flatMap((id, index) => [ + { id: `t-${id}-out`, source: id, target: `e-${id}` }, + ...(index + 1 < length + ? [{ id: `t-${id}-on`, source: id, target: teeth[index + 1] ?? id }] + : []), + ]), + ], + fragments: [], + projections: [], + }, + ends: exits, + }; +}; + +/** Two combs of the same length entered from one message and joined again at a last message, whose teeth exit to the same activities, so merging their targets must remove duplicates. */ +const diamondOfCombs = ( + length: number, +): { readonly model: Model; readonly ends: string[] } => { + const shared = Array.from({ length }, (_, index) => `e-${String(index)}`); + const arm = (side: string): string[] => + shared.map((_, index) => `${side}-${String(index)}`); + const left = arm("l"); + const right = arm("r"); + const message = (id: string) => ({ + kind: "message" as const, + id, + label: id, + from: "x", + to: "y", + mode: "async" as const, + }); + const teeth = (side: readonly string[]) => + side.flatMap((id, index) => [ + { id: `t-${id}-out`, source: id, target: shared[index] ?? id }, + { + id: `t-${id}-on`, + source: id, + target: side[index + 1] ?? "join", + }, + ]); + + return { + model: { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "s", label: "S" }, + message("fork"), + ...left.map(message), + ...right.map(message), + message("join"), + ...shared.map((id) => ({ kind: "activity" as const, id, label: id })), + { kind: "activity", id: "last", label: "Last" }, + ], + transitions: [ + { id: "t-in", source: "s", target: "fork" }, + { id: "t-left", source: "fork", target: left[0] ?? "join" }, + { id: "t-right", source: "fork", target: right[0] ?? "join" }, + ...teeth(left), + ...teeth(right), + { id: "t-last", source: "join", target: "last" }, + ], + fragments: [], + projections: [], + }, + ends: [...shared, "last"], + }; +}; + +/** References the index stores building and reading the targets from the entry activity. */ +const storedReferences = (model: Model): number => { + let stored = 0; + const messages = new Set( + model.elements.flatMap((element) => + element.kind === "message" ? [element.id] : [], + ), + ); + const index = chainIndex(model, { + folded: (element) => messages.has(element), + accepted: (element) => !messages.has(element), + observe: (references) => { + stored += references; + }, + }); + for (const first of model.transitions.filter( + (transition) => transition.source === "s", + )) { + index.from(first); + } + + return stored; +}; + +/** Doubling a model of linear size may at most double what the index stores. */ +const doubling = 2; + +describe("the index's stored size", () => { + it("stays linear for a comb whose targets grow at every message", () => { + expect( + storedReferences(comb(doubling * combLength).model), + ).toBeLessThanOrEqual(doubling * storedReferences(comb(combLength).model)); + }); + + it("stays linear for a diamond of combs", () => { + expect( + storedReferences(diamondOfCombs(doubling * combLength).model), + ).toBeLessThanOrEqual( + doubling * storedReferences(diamondOfCombs(combLength).model), + ); + }); + + it("reaches every tooth of a comb in order", () => { + const { model, ends: teeth } = comb(combLength); + + expect(toFlowchart(model).edges.map((edge) => edge.to)).toEqual(teeth); + }); + + it("reaches each shared end of a diamond of combs once, in order, then the end after the join", () => { + const { model, ends: reached } = diamondOfCombs(combLength); + + expect(toFlowchart(model).edges.map((edge) => edge.to)).toEqual(reached); + }); +}); diff --git a/packages/core/graph/projection/src/diff.ts b/packages/core/graph/projection/src/diff.ts new file mode 100644 index 0000000..c317ea4 --- /dev/null +++ b/packages/core/graph/projection/src/diff.ts @@ -0,0 +1,402 @@ +import type { + Element, + IdSpace, + Model, + Transition, +} from "@exadev/process-graph-schema"; + +/** Where a transition runs from and to. */ +export interface Ends { + readonly source: I["element"]; + readonly target: I["element"]; +} + +/** Who sends and who receives a `message` element. */ +export interface Addressing { + readonly from: I["actor"]; + readonly to: I["actor"]; +} + +/** + * A change to one element that an edited view implies. `inferred` is true when the view did not determine the change and the inverse chose it, for instance a new step a sequence diagram implies; such a change stays unapplied until it is confirmed with `confirmChanges`. + */ +export type ElementChange = + | { + readonly kind: "added"; + readonly element: Element; + readonly inferred: boolean; + } + | { + readonly kind: "removed"; + readonly element: I["element"]; + readonly inferred: boolean; + } + | { + readonly kind: "renamed"; + readonly element: I["element"]; + readonly from: string; + readonly to: string; + readonly inferred: boolean; + } + /** The element moved to another actor's lane; `undefined` is no actor. */ + | { + readonly kind: "relaned"; + readonly element: I["element"]; + readonly from: I["actor"] | undefined; + readonly to: I["actor"] | undefined; + readonly inferred: boolean; + } + /** A `message` element now goes between other actors. */ + | { + readonly kind: "readdressed"; + readonly element: I["element"]; + readonly from: Addressing; + readonly to: Addressing; + readonly inferred: boolean; + }; + +/** A change to one of a transition's labels: its human `guard`, its `trigger` event or its `effect` action. */ +export type TransitionFieldChange = + | { + readonly field: "guard"; + readonly from: string | undefined; + readonly to: string | undefined; + } + | { + readonly field: "trigger"; + readonly from: I["event"] | undefined; + readonly to: I["event"] | undefined; + } + | { + readonly field: "effect"; + readonly from: I["action"] | undefined; + readonly to: I["action"] | undefined; + }; + +/** A change to one transition that an edited view implies. `inferred` has the same meaning as on `ElementChange`. */ +export type TransitionChange = + | { + readonly kind: "added"; + readonly transition: Transition; + readonly inferred: boolean; + } + | { + readonly kind: "removed"; + readonly transition: I["transition"]; + readonly inferred: boolean; + } + | { + readonly kind: "rewired"; + readonly transition: I["transition"]; + readonly from: Ends; + readonly to: Ends; + readonly inferred: boolean; + } + | (TransitionFieldChange & { + readonly kind: "changed"; + readonly transition: I["transition"]; + readonly inferred: boolean; + }); + +/** A renamed event: a sequence message labelled by its trigger's name was relabelled. */ +export interface EventChange { + readonly kind: "renamed"; + readonly event: I["event"]; + readonly from: string; + readonly to: string; + readonly inferred: boolean; +} + +/** What an edited view changes in the model, by identifier. Each list is in the order the changes were found: the model's order for what was there, then the view's order for what is new. */ +export interface ModelDiff { + readonly elements: readonly ElementChange[]; + readonly transitions: readonly TransitionChange[]; + readonly events: readonly EventChange[]; +} + +/** Any one change in a `ModelDiff`. */ +export type Change = + ElementChange | TransitionChange | EventChange; + +/** Thrown by `applyModelDiff` when the diff still holds changes the inverse inferred and nobody confirmed. */ +export class UnconfirmedChangeError extends Error { + /** The changes still marked `inferred`. */ + readonly changes: readonly Change[]; + + constructor(changes: readonly Change[]) { + super( + `The diff holds inferred changes that have not been confirmed: ${changes + .map((change) => change.kind) + .join(", ")}`, + ); + this.name = "UnconfirmedChangeError"; + this.changes = changes; + } +} + +/** + * Marks inferred changes as confirmed, so `applyModelDiff` will apply them. + * @param diff - A diff returned by one of the view inverses. + * @param accept - Chooses which inferred changes to confirm; the rest stay inferred. + * @returns A new diff; `diff` is not modified. + */ +export const confirmChanges = ( + diff: ModelDiff, + accept: (change: Change) => boolean, +): ModelDiff => ({ + elements: diff.elements.map((change) => + change.inferred && accept(change) ? { ...change, inferred: false } : change, + ), + transitions: diff.transitions.map((change) => + change.inferred && accept(change) ? { ...change, inferred: false } : change, + ), + events: diff.events.map((change) => + change.inferred && accept(change) ? { ...change, inferred: false } : change, + ), +}); + +type ElementOf = Element; +type TransitionOf = Transition; + +/** A copy of `value` without `key`, for clearing an optional field, which `exactOptionalPropertyTypes` will not let be set to `undefined`. */ +const without = ( + value: T, + key: K, +): Omit => { + const copy = { ...value }; + Reflect.deleteProperty(copy, key); + + return copy; +}; + +/** Applies one change to the element it names; additions and removals are applied to the element list instead. */ +const applyElementChange = ( + element: ElementOf, + change: ElementChange, +): ElementOf => { + if (change.kind === "renamed") { + if (element.kind === "pseudo") { + throw new Error( + `Element "${element.id}" is a pseudo-state and has no label to rename`, + ); + } + + return { ...element, label: change.to }; + } + if (change.kind === "relaned") { + if (element.kind !== "activity" && element.kind !== "decision") { + throw new Error( + `Element "${element.id}" is a ${element.kind} and has no actor to move`, + ); + } + + return change.to === undefined + ? without(element, "actor") + : { ...element, actor: change.to }; + } + if (change.kind === "readdressed") { + if (element.kind !== "message") { + throw new Error( + `Element "${element.id}" is a ${element.kind}, not a message`, + ); + } + + return { ...element, from: change.to.from, to: change.to.to }; + } + + return element; +}; + +/** Applies one change to the transition it names; additions and removals are applied to the transition list instead. */ +const applyTransitionChange = ( + transition: TransitionOf, + change: TransitionChange, +): TransitionOf => { + if (change.kind === "rewired") { + return { ...transition, ...change.to }; + } + if (change.kind !== "changed") { + return transition; + } + if (change.field === "guard") { + return change.to === undefined + ? without(transition, "guard") + : { ...transition, guard: change.to }; + } + if (change.field === "trigger") { + return change.to === undefined + ? without(transition, "trigger") + : { ...transition, trigger: change.to }; + } + + return change.to === undefined + ? without(transition, "effect") + : { ...transition, effect: change.to }; +}; + +/** The items of `items` grouped by the key `keyOf` gives each, every group in the order of `items`; one pass, so looking a group up afterwards costs nothing per item. */ +const groupBy = ( + items: readonly Item[], + keyOf: (item: Item) => Key, +): ReadonlyMap => { + const groups = new Map(); + for (const item of items) { + const key = keyOf(item); + const group = groups.get(key); + if (group === undefined) { + groups.set(key, [item]); + } else { + group.push(item); + } + } + + return groups; +}; + +/** The identifier a change is about, used to find what it applies to. */ +const subjectOf = ( + change: ElementChange, +): I["element"] => + change.kind === "added" ? change.element.id : change.element; + +const transitionSubjectOf = ( + change: TransitionChange, +): I["transition"] => + change.kind === "added" ? change.transition.id : change.transition; + +/** + * Applies a confirmed diff to the model, returning a new model. + * + * Removing an element also takes it out of fragment bodies and recorded positions; removing a transition also takes it out of every sequence scenario. Transitions that touched a removed element are only removed when the diff says so, as every view inverse does, so a diff built by hand that leaves one dangling yields a model `validate` reports. + * @param model - The model the diff was computed against. + * @param diff - The changes, with every inferred one confirmed. + * @returns The changed model; `model` is not modified. + * @throws UnconfirmedChangeError when a change is still marked `inferred`. + * @throws Error when a change names an element, transition or event the model does not have, or asks for something the element's kind cannot carry, such as a label on a pseudo-state. + */ +export const applyModelDiff = ( + model: Model, + diff: ModelDiff, +): Model => { + const unconfirmed = [ + ...diff.elements, + ...diff.transitions, + ...diff.events, + ].filter((change) => change.inferred); + if (unconfirmed.length > 0) { + throw new UnconfirmedChangeError(unconfirmed); + } + + const elementIds = new Set( + model.elements.map((element) => element.id), + ); + for (const change of diff.elements) { + if (change.kind !== "added" && !elementIds.has(subjectOf(change))) { + throw new Error(`Element "${subjectOf(change)}" is not in the model`); + } + } + const transitionIds = new Set( + model.transitions.map((transition) => transition.id), + ); + for (const change of diff.transitions) { + const id = transitionSubjectOf(change); + if (change.kind !== "added" && !transitionIds.has(id)) { + throw new Error(`Transition "${id}" is not in the model`); + } + } + const eventIds = new Set(model.events.map((event) => event.id)); + for (const change of diff.events) { + if (!eventIds.has(change.event)) { + throw new Error(`Event "${change.event}" is not in the model`); + } + } + + const removedElements = new Set( + diff.elements.flatMap((change) => + change.kind === "removed" ? [change.element] : [], + ), + ); + const removedTransitions = new Set( + diff.transitions.flatMap((change) => + change.kind === "removed" ? [change.transition] : [], + ), + ); + + // Each change list is grouped by the identifier it is about once, so every element, transition and event reads only its own changes. + const elementChanges = groupBy(diff.elements, subjectOf); + const transitionChanges = groupBy(diff.transitions, transitionSubjectOf); + const eventChanges = groupBy(diff.events, (change) => change.event); + + const elements = [ + ...model.elements + .filter((element) => !removedElements.has(element.id)) + .map((element) => + (elementChanges.get(element.id) ?? []).reduce( + applyElementChange, + element, + ), + ), + ...diff.elements.flatMap((change) => + change.kind === "added" ? [change.element] : [], + ), + ]; + const transitions = [ + ...model.transitions + .filter((transition) => !removedTransitions.has(transition.id)) + .map((transition) => + (transitionChanges.get(transition.id) ?? []).reduce( + applyTransitionChange, + transition, + ), + ), + ...diff.transitions.flatMap((change) => + change.kind === "added" ? [change.transition] : [], + ), + ]; + const events = model.events.map((event) => + (eventChanges.get(event.id) ?? []).reduce( + (renamed, change) => ({ ...renamed, name: change.to }), + event, + ), + ); + const fragments = model.fragments.map((fragment) => ({ + ...fragment, + operands: fragment.operands.map((operand) => ({ + ...operand, + body: operand.body.filter((id) => !removedElements.has(id)), + })), + })); + const projections = model.projections.map((projection) => + projection.notation === "sequence" + ? { + ...projection, + scenario: projection.scenario.filter( + (id) => !removedTransitions.has(id), + ), + } + : projection, + ); + const views = + model.views === undefined + ? undefined + : Object.fromEntries( + Object.entries(model.views).map(([notation, positions]) => [ + notation, + Object.fromEntries( + Object.entries(positions).filter( + ([id]) => !removedElements.has(id), + ), + ), + ]), + ); + + return { + ...model, + elements, + transitions, + events, + fragments, + projections, + ...(views === undefined ? {} : { views }), + }; +}; diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index 83f5812..733e054 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -1,5 +1,12 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; +import { + chainIndex, + once, + type Chain, + type ChainIndex, + type TransitionOf, +} from "./chains.ts"; import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface FlowNode { @@ -8,10 +15,16 @@ export interface FlowNode { readonly shape: "terminator" | "process" | "decision" | "bar"; } +/** A drawn edge. It stands for one model transition, or for a chain of them through `message` elements, which a flowchart draws as the edge that carries the message rather than as a node. */ export interface FlowEdge { readonly from: I["element"]; readonly to: I["element"]; + /** On a single route, its guards joined with "and", or, when there are none, the labels of the messages it carries. On several merged routes, the labels of the messages it carries. */ readonly label?: string; + /** The model transitions the edge stands for: one, or every transition on some route through the message elements it carries, each once, in the order they were met. An edit to the edge maps back to the model through these. */ + readonly transitions: readonly I["transition"][]; + /** The `message` elements the edge carries, each once, in the order they were met, when it stands for more than one transition. */ + readonly through?: readonly I["element"][]; } export interface Flowchart { @@ -51,33 +64,118 @@ const flowNode = ( return undefined; }; +/** What a flowchart-shaped projection drew, for working out what it dropped. */ +export interface Drawn { + readonly nodes: readonly FlowNode[]; + /** The message elements some edge carries, which are shown. */ + readonly carried: ReadonlySet; + /** The message elements left unfolded because folding them would multiply edges, reported as unrepresented instead. */ + readonly unfolded: ReadonlySet; +} + /** * What a flowchart-shaped projection drops: the common losses, then the elements the flowchart has no node for (`state` elements and `history` pseudo-states) and the transitions that touch them. * @param model - The model projected. * @param notation - The notation the result is for, `flowchart` or `swimlane`. - * @param nodes - The nodes the projection drew. + * @param drawn - What the projection drew. */ export const flowchartLoss = ( model: Model, notation: "flowchart" | "swimlane", - nodes: readonly FlowNode[], + drawn: Drawn, ): readonly Loss[] => [ - ...commonLoss(model, notation), + ...commonLoss(model, notation, { + fragments: new Set(), + messages: new Set([...drawn.carried, ...drawn.unfolded]), + }), ...unrepresentedLoss( model, notation, - new Set(nodes.map((node) => node.id)), + new Set(drawn.nodes.map((node) => node.id)), + drawn.unfolded, ), ]; +/** Where a drawn edge comes from: the transition it starts with and the node it ends at. */ +export interface EdgeOrigin { + readonly first: TransitionOf; + readonly target: I["element"]; +} + +/** A flowchart with what its inverse and the swimlane need beside it. */ +export interface FlowchartParts { + readonly flowchart: Flowchart; + /** Each edge's origin, in edge order. */ + readonly origins: readonly EdgeOrigin[]; + readonly index: ChainIndex; + /** The message elements some edge carries. */ + readonly carried: ReadonlySet; +} + /** - * Activities, decisions and terminators, joined by the transitions between them. `state` elements and `history` pseudo-states have no flowchart shape and are reported as unrepresented, with the transitions that touch them. + * The edge a chain draws. On a single route its label is the route's guards joined with "and", or, when none is guarded, the labels of the messages on it; where several routes are merged the guards belong to different routes and cannot be joined, so the label is the carried messages' labels. An edge through messages builds its lists and label only when they are read, so edges sharing a long run of messages do not each copy it. */ -export const toFlowchart = ( +const edgeOf = ( + chain: Chain, + messageLabels: ReadonlyMap, +): FlowEdge => { + const from = chain.first.source; + if (chain.first.target === chain.target) { + const { guard } = chain.first; + + return { + from, + to: chain.target, + ...(guard === undefined ? {} : { label: guard }), + transitions: [chain.first.id], + }; + } + const ids = once(() => + chain.transitions().map((transition) => transition.id), + ); + const label = once(() => { + if (!chain.merged && chain.guarded) { + return chain.guards().join(" and "); + } + + return chain + .through() + .flatMap((id) => { + const message = messageLabels.get(id); + + return message === undefined ? [] : [message]; + }) + .join(", "); + }); + + return { + from, + to: chain.target, + get label(): string { + return label(); + }, + get transitions(): readonly I["transition"][] { + return ids(); + }, + get through(): readonly I["element"][] { + return chain.through(); + }, + }; +}; + +/** + * The flowchart and the parts its inverse needs. + * @param model - The model to project. + */ +export const flowchartParts = ( model: Model, -): Flowchart => { +): FlowchartParts => { const nodes: FlowNode[] = []; + const messageLabels = new Map(); for (const element of model.elements) { + if (element.kind === "message") { + messageLabels.set(element.id, element.label); + } const node = flowNode(element); if (node !== undefined) { nodes.push(node); @@ -85,20 +183,43 @@ export const toFlowchart = ( } const included = new Set(nodes.map((node) => node.id)); + const index = chainIndex(model, { + folded: (id) => messageLabels.has(id), + accepted: (id) => included.has(id), + }); + const firsts = model.transitions.filter((transition) => + included.has(transition.source), + ); const edges: FlowEdge[] = []; - for (const transition of model.transitions) { - if (included.has(transition.source) && included.has(transition.target)) { - edges.push( - transition.guard === undefined - ? { from: transition.source, to: transition.target } - : { - from: transition.source, - to: transition.target, - label: transition.guard, - }, - ); + const origins: EdgeOrigin[] = []; + for (const first of firsts) { + for (const chain of index.from(first)) { + edges.push(edgeOf(chain, messageLabels)); + origins.push({ first, target: chain.target }); } } + const carried = index.carried(firsts); - return { nodes, edges, loss: flowchartLoss(model, "flowchart", nodes) }; + return { + flowchart: { + nodes, + edges, + loss: flowchartLoss(model, "flowchart", { + nodes, + carried, + unfolded: index.unfolded, + }), + }, + origins, + index, + carried, + }; }; + +/** + * Activities, decisions and terminators, joined by the transitions between them. A `message` element is not a node: a transition into it continues through it, so the edge runs from the step before the message to the step after it and carries the message (`through`). There is one edge per transition leaving a node and node it leads to, however many routes through message elements connect them; such an edge's `transitions`, `through` and `label` are built when first read. A message element no edge carries is reported as a `message-elements` loss. `state` elements and `history` pseudo-states have no flowchart shape and are reported as unrepresented, with the transitions that touch them. + * @param model - The model to project. + * @returns The nodes in model order, the edges in the order of the transitions they start with, and what was dropped. + */ +export const toFlowchart = (model: Model): Flowchart => + flowchartParts(model).flowchart; diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts new file mode 100644 index 0000000..0f4940e --- /dev/null +++ b/packages/core/graph/projection/src/fragments.ts @@ -0,0 +1,847 @@ +import { + buildDag, + immediateDominators, + stronglyConnectedComponents, +} from "@exadev/process-graph-dag"; +import type { + Fragment, + FragmentOperator, + IdSpace, + Model, +} from "@exadev/process-graph-schema"; +import { outgoingMap, type TransitionOf } from "./chains.ts"; +import type { + SequenceItem, + SequenceMessage, + SequenceOperand, +} from "./sequence.ts"; + +/** A drawn message with its position among the drawn messages and the element it arrives at. */ +export interface Entry { + readonly index: number; + readonly message: SequenceMessage; + readonly target: I["element"]; +} + +/** One scenario step and how many messages were drawn before it, so the messages a run of steps draws are a range of positions. */ +export interface Step { + readonly transition: TransitionOf; + readonly before: number; +} + +/** The messages an operand holds: a run of consecutive positions given by its ends, or an ascending list of positions. A run is never expanded into its positions. */ +type Held = + | { + readonly from: number; + readonly to: number; + readonly positions?: never; + readonly elements?: never; + } + | { + readonly positions: readonly number[]; + readonly from?: never; + readonly elements?: never; + } + | { + /** The elements whose messages the operand holds, each with the ascending positions of the messages arriving at it; the lists are shared by every operand holding the same element, never copied. */ + readonly elements: readonly { + readonly element: string; + readonly positions: readonly number[]; + }[]; + readonly from?: never; + readonly positions?: never; + }; + +type Operand = { readonly guard: string | undefined } & Held; + +/** A fragment that may be drawn, with the messages each of its operands holds; operands never share a position. */ +interface Candidate { + /** The authored fragment's id; absent for a derived fragment. */ + readonly authored: I["fragment"] | undefined; + readonly operands: readonly Operand[]; + /** Builds the item drawn for it from its operands' items. */ + readonly item: (operands: readonly SequenceOperand[]) => SequenceItem; +} + +/** A choice or a split needs at least two branches, and an `opt` is exactly a choice between doing something and skipping it. */ +const minBranches = 2; + +/** The virtual end every last step leads to, for finding where branches meet. A number, so it never equals an element id, which is a string. */ +const end = 0; + +const derivedItem = + (element: I["element"], operator: FragmentOperator) => + (operands: readonly SequenceOperand[]): SequenceItem => ({ + kind: "fragment", + element, + operator, + operands, + }); + +/** Appends `value` to the list `map` holds under `key`, starting the list when there is none. */ +const append = ( + map: Map, + key: Key, + value: Value, +): void => { + const list = map.get(key); + if (list === undefined) { + map.set(key, [value]); + } else { + list.push(value); + } +}; + +/** Counts work done over message positions, so a caller can measure it. */ +export type Observe = (positions: number) => void; + +/** One scenario as fragments see it: the model, its steps, the messages drawn, the elements it passes, and an optional work counter. */ +export interface ScenarioPath { + readonly model: Model; + readonly steps: readonly Step[]; + readonly entries: readonly Entry[]; + readonly onPath: ReadonlySet; + readonly observe?: Observe; +} + +/** + * The fragments the model's structure implies along the scenario, in model order of the decision or fork each comes from, each decision's loop before its other fragments. + * + * - A decision transition whose target is in the decision's strongly connected component goes round again, the rule `graph-domain` uses for a legal cycle. Each component holding such a decision on the path draws one `loop`, holding the messages that arrive inside the component, guarded by those transitions' guards joined with "or". + * - Each time the path takes one of a decision's other (forward) branches, when it has at least two, an `alt` holds the messages from that step until the path reaches where the branches meet: the decision's immediate post-dominator over forward transitions, or the end of the path when they never meet. With exactly two branches, one going straight to where they meet, it is an `opt`, and taking that empty branch draws nothing. The path coming back to the decision ends the earlier occurrence. + * - Each time the path leaves a fork, a `par` holds one operand per branch taken, each with the messages of the steps that follow on from that branch until it reaches the fork's immediate post-dominator; a branch taken again starts a new `par`. + * + * One pass finds every component and one pass every post-dominator, both only when the path passes a decision or fork, and the path is then read once, so the work is the size of the model and of the path, not their product. + */ +const deriveCandidates = ({ + model, + steps, + entries, + onPath, + observe, +}: ScenarioPath): Candidate[] => { + const branching = model.elements.filter( + (element) => + onPath.has(element.id) && + (element.kind === "decision" || + (element.kind === "pseudo" && element.role === "fork")), + ); + if (branching.length === 0) { + return []; + } + + const outgoing = outgoingMap(model); + const ids = model.elements.map((element) => element.id); + const component = new Map(); + for (const members of stronglyConnectedComponents( + buildDag(ids, (id) => + (outgoing.get(id) ?? []).map((transition) => transition.target), + ), + )) { + for (const member of members) { + component.set(member, members); + } + } + const loops = new Set(); + for (const element of model.elements) { + const own = component.get(element.id); + if (element.kind !== "decision" || own === undefined) { + continue; + } + for (const transition of outgoing.get(element.id) ?? []) { + if (component.get(transition.target) === own) { + loops.add(transition.id); + } + } + } + + // Post-dominators over forward transitions: read with each element needing the elements before it and the virtual end needing every element with no forward way on, the immediate dominators from the end are where each element's ways forward meet. + const before = new Map(); + const last: I["element"][] = []; + for (const id of ids) { + const forward = (outgoing.get(id) ?? []).filter( + (transition) => !loops.has(transition.id), + ); + if (forward.length === 0) { + last.push(id); + } + for (const transition of forward) { + append(before, transition.target, id); + } + } + const meets = immediateDominators( + buildDag([end, ...ids], (id) => + id === end ? last : (before.get(id) ?? []), + ), + end, + ); + /** Where an element's ways forward meet, or `undefined` when they only meet at the end. */ + const meetOf = (id: I["element"]): I["element"] | undefined => { + const meet = meets.get(id); + + return meet === undefined || meet === end ? undefined : meet; + }; + + const found = new Map[]>(); + const add = (element: I["element"], candidate: Candidate): void => { + append(found, element, candidate); + }; + + // Loops: one per component, named by its first decision on the path in model order, holding the messages that arrive inside it. + const looping = new Map(); + const loopGuards = new Map>(); + for (const element of branching) { + const own = component.get(element.id); + const back = (outgoing.get(element.id) ?? []).filter((transition) => + loops.has(transition.id), + ); + if (own === undefined || back.length === 0) { + continue; + } + if (!looping.has(own)) { + looping.set(own, element.id); + loopGuards.set(own, new Set()); + } + for (const transition of back) { + if (transition.guard !== undefined) { + loopGuards.get(own)?.add(transition.guard); + } + } + } + const inside = new Map(); + for (const entry of entries) { + const own = component.get(entry.target); + if (own !== undefined && looping.has(own)) { + observe?.(1); + append(inside, own, entry.index); + } + } + for (const [own, element] of looping) { + const guards = [...(loopGuards.get(own) ?? [])]; + add(element, { + authored: undefined, + operands: [ + { + guard: guards.length > 0 ? guards.join(" or ") : undefined, + positions: inside.get(own) ?? [], + }, + ], + item: derivedItem(element, "loop"), + }); + } + + /** Each branching element's forward branch count, where they meet, and, for a decision, whether it draws an `opt`: found once per element, not once per step that leaves it. */ + const shapes = new Map< + I["element"], + { + readonly branches: number; + readonly meet: I["element"] | undefined; + readonly operator: "alt" | "opt"; + } + >(); + for (const element of branching) { + const forward = (outgoing.get(element.id) ?? []).filter( + (transition) => !loops.has(transition.id), + ); + const meet = meetOf(element.id); + const direct = forward.filter((branch) => branch.target === meet).length; + shapes.set(element.id, { + branches: forward.length, + meet, + operator: forward.length === minBranches && direct === 1 ? "opt" : "alt", + }); + } + const decisions = new Set( + branching.flatMap((element) => + element.kind === "decision" ? [element.id] : [], + ), + ); + + // Alternatives: occurrences open when a forward branch is taken and close where the branches meet. + interface Occurrence { + readonly element: I["element"]; + readonly operator: "alt" | "opt"; + readonly guard: string | undefined; + readonly start: number; + } + const waiting = new Map(); + const openAt = new Map(); + const closed = new Set(); + const close = (occurrence: Occurrence, stop: number): void => { + if (closed.has(occurrence)) { + return; + } + closed.add(occurrence); + if (openAt.get(occurrence.element) === occurrence) { + openAt.delete(occurrence.element); + } + if (stop > occurrence.start) { + add(occurrence.element, { + authored: undefined, + operands: [ + { guard: occurrence.guard, from: occurrence.start, to: stop - 1 }, + ], + item: derivedItem(occurrence.element, occurrence.operator), + }); + } + }; + + // Parallel branches: a label on each element reached says which branch of which open `par` it is on; reaching the fork's post-dominator hands back the label the fork had. + interface Parallel { + readonly element: I["element"]; + readonly meet: I["element"] | undefined; + readonly outer: Label | undefined; + readonly operands: { + readonly guard: string | undefined; + readonly positions: number[]; + }[]; + readonly taken: Set; + } + interface Label { + readonly parallel: Parallel; + readonly operand: number; + } + const labels = new Map(); + const openParallel = new Map(); + const parallels: Parallel[] = []; + /** The label a step carrying `label` leaves on `target`: the enclosing one when `target` is where its `par` meets. */ + const after = ( + label: Label | undefined, + target: I["element"], + ): Label | undefined => + label?.parallel.meet === target ? label.parallel.outer : label; + const relabel = (target: I["element"], label: Label | undefined): void => { + const next = after(label, target); + if (next === undefined) { + labels.delete(target); + } else { + labels.set(target, next); + } + }; + + for (const [index, step] of steps.entries()) { + const { transition } = step; + const drawn = steps[index + 1]?.before ?? entries.length; + const position = drawn > step.before ? step.before : undefined; + const source = transition.source; + const shape = shapes.get(source); + + if ( + decisions.has(source) && + shape !== undefined && + shape.branches >= minBranches && + !loops.has(transition.id) + ) { + const earlier = openAt.get(source); + if (earlier !== undefined) { + close(earlier, step.before); + } + const { meet, operator } = shape; + if (!(operator === "opt" && transition.target === meet)) { + const occurrence: Occurrence = { + element: source, + operator, + guard: transition.guard, + start: step.before, + }; + openAt.set(source, occurrence); + if (meet !== undefined) { + append(waiting, meet, occurrence); + } + } + } + + let label = labels.get(source); + if ( + !decisions.has(source) && + shape !== undefined && + shape.branches >= minBranches + ) { + let parallel = openParallel.get(source); + if (parallel === undefined || parallel.taken.has(transition.id)) { + parallel = { + element: source, + meet: shape.meet, + outer: label, + operands: [], + taken: new Set(), + }; + openParallel.set(source, parallel); + parallels.push(parallel); + } + parallel.taken.add(transition.id); + parallel.operands.push({ guard: transition.guard, positions: [] }); + label = { parallel, operand: parallel.operands.length - 1 }; + } + // The message of a step arriving where its branch's `par` meets lies outside that `par`. + const holder = after(label, transition.target); + if (holder !== undefined && position !== undefined) { + observe?.(1); + holder.parallel.operands[holder.operand]?.positions.push(position); + } + relabel(transition.target, label); + + for (const occurrence of waiting.get(transition.target) ?? []) { + close(occurrence, step.before); + } + waiting.delete(transition.target); + } + for (const occurrence of openAt.values()) { + close(occurrence, entries.length); + } + for (const parallel of parallels) { + add(parallel.element, { + authored: undefined, + operands: parallel.operands, + item: derivedItem(parallel.element, "par"), + }); + } + + return model.elements.flatMap((element) => found.get(element.id) ?? []); +}; + +/** + * The authored fragments as candidates. An operand counts when its body is non-empty and wholly on the path; it holds the messages arriving at an element of its body that no earlier operand of the same fragment holds. The positions arriving at each element are found once and shared by every operand holding that element, so an operand is its elements and their shared lists, and matching costs the messages plus the bodies, never a message once per fragment. + */ +const authoredCandidates = ( + fragments: readonly Fragment[], + entries: readonly Entry[], + onPath: ReadonlySet, + observe: Observe | undefined, +): Candidate[] => { + // The positions of the messages arriving at each element, found once and shared by every operand whose body holds the element. + const arrivals = new Map(); + for (const entry of entries) { + observe?.(1); + append(arrivals, entry.target, entry.index); + } + + return fragments.map(({ id, operator, operands }) => { + // An element belongs to the first operand of the fragment whose body holds it, so a message is held by that operand only. + const taken = new Set(); + const held: Operand[] = []; + for (const operand of operands) { + if ( + operand.body.length === 0 || + !operand.body.every((element) => onPath.has(element)) + ) { + continue; + } + const elements: { + readonly element: string; + readonly positions: readonly number[]; + }[] = []; + for (const element of operand.body) { + observe?.(1); + const positions = arrivals.get(element); + if (!taken.has(element) && positions !== undefined) { + elements.push({ element, positions }); + } + taken.add(element); + } + if (elements.length > 0) { + held.push({ guard: operand.guard, elements }); + } + } + + return { + authored: id, + operands: held, + item: (drawn): SequenceItem => ({ + kind: "fragment", + fragment: id, + operator, + operands: drawn, + }), + }; + }); +}; + +/** A fragment placed in the diagram: its candidate, where its messages run, which operand holds each, and the operand of the fragment it sits in. */ +interface Placed { + readonly candidate: Candidate; + readonly first: number; + readonly last: number; + readonly slotOf: (position: number) => number; + /** The element the message at a position arrives at. */ + readonly elementAt: (position: number) => string | undefined; + readonly parent: + { readonly placed: Placed; readonly slot: number } | undefined; + readonly order: number; +} + +/** How many of the ascending `positions` lie between `from` and `to` inclusive, by two binary searches. */ +const countBetween = ( + positions: readonly number[], + from: number, + to: number, +): number => { + const firstAtLeast = (value: number): number => { + let low = 0; + let high = positions.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((positions[middle] ?? value) < value) { + low = middle + 1; + } else { + high = middle; + } + } + + return low; + }; + + return firstAtLeast(to + 1) - firstAtLeast(from); +}; + +/** The first and last position an operand holds and how many it holds, from its ends or its list's ends. */ +const extentOf = ( + held: Held, +): + | { readonly first: number; readonly last: number; readonly count: number } + | undefined => { + if (held.elements !== undefined) { + let first = Number.POSITIVE_INFINITY; + let last = Number.NEGATIVE_INFINITY; + let count = 0; + for (const { positions } of held.elements) { + count += positions.length; + first = Math.min(first, positions[0] ?? first); + last = Math.max(last, positions.at(-1) ?? last); + } + + return count === 0 ? undefined : { first, last, count }; + } + if (held.positions === undefined) { + return held.to < held.from + ? undefined + : { first: held.from, last: held.to, count: held.to - held.from + 1 }; + } + const first = held.positions[0]; + const last = held.positions.at(-1); + + return first === undefined || last === undefined + ? undefined + : { first, last, count: held.positions.length }; +}; + +/** Whether an operand holds every position from `first` to `last`. */ +const holdsAll = (held: Held, first: number, last: number): boolean => { + if (held.elements !== undefined) { + let count = 0; + for (const { positions } of held.elements) { + count += countBetween(positions, first, last); + } + + return count === last - first + 1; + } + + return held.positions === undefined + ? held.from <= first && last <= held.to + : countBetween(held.positions, first, last) === last - first + 1; +}; + +/** Each operand's body elements as a set, built the first time the operand is asked whether it holds a nested fragment. */ +const elementSets = new WeakMap>(); + +/** + * Whether a fragment spanning `first` to `last` lies wholly inside operand `slot` of the placed fragment around it, in constant or logarithmic time per check. The outer fragment's messages are consecutive, and both ends of the inner span lie inside it, so a fragment with one operand holds the whole span. A run or list of positions is checked by its ends or by binary search. An operand held by elements holds every message arriving at them, so an authored inner fragment, whose messages are exactly its own elements' and fill the span, fits when its elements are among the operand's; any other inner span is checked against the operand's elements by binary search when they are fewer than the span's positions, and otherwise position by position. + */ +const fits = ( + outer: Placed, + slot: number, + inner: Candidate, + { first, last }: { readonly first: number; readonly last: number }, +): boolean => { + const { operands } = outer.candidate; + const operand = operands[slot]; + if (operand === undefined) { + return false; + } + if (operands.length === 1) { + return true; + } + if (operand.elements === undefined) { + return holdsAll(operand, first, last); + } + let elements = elementSets.get(operand); + if (elements === undefined) { + elements = new Set(operand.elements.map(({ element }) => element)); + elementSets.set(operand, elements); + } + const held = elements; + const innerElements = inner.operands.flatMap((part) => + part.elements === undefined ? [] : [part.elements], + ); + if (innerElements.length === inner.operands.length) { + return innerElements.every((list) => + list.every(({ element }) => held.has(element)), + ); + } + if (operand.elements.length <= last - first + 1) { + return holdsAll(operand, first, last); + } + for (let position = first; position <= last; position += 1) { + const element = outer.elementAt(position); + if (element === undefined || !held.has(element)) { + return false; + } + } + + return true; +}; + +/** + * Which of a fragment's operands holds a position: listed positions are looked up in a map built from the lists, which together hold no more positions than the fragment does, and runs are found by a binary search over their starts. No run is expanded. + */ +const slotIndex = ( + operands: readonly Operand[], + elementAt: (position: number) => string | undefined, + observe: Observe | undefined, +): ((position: number) => number) => { + if (operands.length === 1) { + return () => 0; + } + const listed = new Map(); + const byElement = new Map(); + const runs: { + readonly from: number; + readonly to: number; + readonly slot: number; + }[] = []; + for (const [slot, operand] of operands.entries()) { + if (operand.elements !== undefined) { + for (const { element } of operand.elements) { + observe?.(1); + byElement.set(element, slot); + } + continue; + } + if (operand.positions === undefined) { + runs.push({ from: operand.from, to: operand.to, slot }); + continue; + } + for (const position of operand.positions) { + observe?.(1); + listed.set(position, slot); + } + } + runs.sort((a, b) => a.from - b.from); + + return (position) => { + const element = elementAt(position); + const found = + listed.get(position) ?? + (element === undefined ? undefined : byElement.get(element)); + if (found !== undefined) { + return found; + } + let low = 0; + let high = runs.length; + while (low < high) { + const middle = (low + high) >>> 1; + if ((runs[middle]?.from ?? position) <= position) { + low = middle + 1; + } else { + high = middle; + } + } + const run = runs[low - 1]; + + return run !== undefined && position <= run.to ? run.slot : 0; + }; +}; + +/** + * Which placed fragment is innermost at each position, without visiting the positions a fragment covers: fragments are placed largest first, so the innermost at a position is the one placed last among those covering it. A segment tree over the positions records, on the nodes that exactly cover each placed span, when it was placed; the innermost at a position is the latest placement on the path from its leaf to the root. Placing and asking each visit a number of nodes that grows with the logarithm of the number of positions. + */ +const placementTree = ( + size: number, +): { + readonly place: (first: number, last: number, time: number) => void; + readonly at: (position: number) => number | undefined; +} => { + const latest: (number | undefined)[] = Array.from( + { length: 2 * size }, + () => undefined, + ); + + return { + place: (first, last, time) => { + let low = first + size; + let high = last + size + 1; + while (low < high) { + if ((low & 1) === 1) { + latest[low] = time; + low += 1; + } + if ((high & 1) === 1) { + high -= 1; + latest[high] = time; + } + low >>>= 1; + high >>>= 1; + } + }, + at: (position) => { + let best: number | undefined; + for (let node = position + size; node >= 1; node >>>= 1) { + const time = latest[node]; + if (time !== undefined && (best === undefined || time > best)) { + best = time; + } + } + + return best; + }, + }; +}; + +/** + * Wraps the scenario's messages in the fragments they fall inside: the authored ones first, then those derived from the model's structure. + * + * A fragment is drawn when it holds at least one message and the messages it holds are consecutive. Larger fragments are placed first; a fragment nests inside a placed one when all its messages lie in one of that fragment's operands, and is left out when it overlaps one without nesting. A derived fragment holding exactly the messages an authored one holds is not drawn: the authored fragment is the author's notation for the same structure. + * + * Fragments are compared and placed by the ends of their spans, and a segment tree answers which placed fragment is innermost at a position, so no span is ever expanded into its positions; the items are then built in one pass over the messages. + * @param path - The scenario; its `observe`, when given, is told of the work done over message positions. + * @returns The items and the ids of the authored fragments drawn. + */ +export const wrapFragments = ( + path: ScenarioPath, +): { + readonly items: SequenceItem[]; + readonly drawn: Set; +} => { + const { model, entries, onPath, observe } = path; + const candidates = [ + ...authoredCandidates(model.fragments, entries, onPath, observe), + ...deriveCandidates(path), + ]; + + const spans: { + readonly candidate: Candidate; + readonly first: number; + readonly last: number; + readonly order: number; + }[] = []; + const authoredSpans = new Set(); + for (const [order, candidate] of candidates.entries()) { + let first = Number.POSITIVE_INFINITY; + let last = Number.NEGATIVE_INFINITY; + let count = 0; + for (const operand of candidate.operands) { + const extent = extentOf(operand); + if (extent !== undefined) { + count += extent.count; + first = Math.min(first, extent.first); + last = Math.max(last, extent.last); + } + } + const key = `${String(first)}:${String(last)}`; + if (count === 0 || last - first + 1 !== count) { + continue; + } + if (candidate.authored !== undefined) { + authoredSpans.add(key); + } else if (authoredSpans.has(key)) { + continue; + } + spans.push({ candidate, first, last, order }); + } + spans.sort( + (a, b) => b.last - b.first - (a.last - a.first) || a.order - b.order, + ); + + const tree = placementTree(entries.length); + const elementAt = (position: number): string | undefined => + entries[position]?.target; + const placed: Placed[] = []; + const innermost = (position: number): Placed | undefined => { + const time = tree.at(position); + + return time === undefined ? undefined : placed[time]; + }; + for (const { candidate, first, last, order } of spans) { + const outer = innermost(first); + if (outer !== innermost(last)) { + continue; + } + let parent: Placed["parent"]; + if (outer !== undefined) { + const slot = outer.slotOf(first); + if (!fits(outer, slot, candidate, { first, last })) { + continue; + } + parent = { placed: outer, slot }; + } + const node: Placed = { + candidate, + first, + last, + slotOf: slotIndex(candidate.operands, elementAt, observe), + elementAt, + parent, + order, + }; + tree.place(first, last, placed.length); + placed.push(node); + } + + // Each placed fragment's operands, and the top level, list what lies directly inside them in order: messages, and fragments at the position they start. + type Content = { readonly entry: Entry } | { readonly placed: Placed }; + const top: Content[] = []; + const contents = new Map, Content[][]>( + placed.map((node) => [node, node.candidate.operands.map(() => [])]), + ); + const contentOf = ( + holder: { readonly placed: Placed; readonly slot: number } | undefined, + ): Content[] => + holder === undefined + ? top + : (contents.get(holder.placed)?.[holder.slot] ?? top); + const starting = new Map[]>(); + for (const node of placed) { + append(starting, node.first, node); + } + for (const entry of entries) { + observe?.(1); + for (const node of starting.get(entry.index) ?? []) { + contentOf(node.parent).push({ placed: node }); + } + const owner = innermost(entry.index); + contentOf( + owner === undefined + ? undefined + : { placed: owner, slot: owner.slotOf(entry.index) }, + ).push({ entry }); + } + + // Inner fragments are placed after the ones they sit in, so building in reverse placement order has every inner item ready before its outer one needs it. + const built = new Map, SequenceItem>(); + const itemsOf = (content: readonly Content[]): SequenceItem[] => + content.flatMap((part): SequenceItem[] => { + if ("entry" in part) { + return [{ kind: "message", message: part.entry.message }]; + } + const item = built.get(part.placed); + + return item === undefined ? [] : [item]; + }); + const drawn = new Set(); + for (const node of placed.toReversed()) { + const operands = (contents.get(node) ?? []).map((content, slot) => { + const guard = node.candidate.operands[slot]?.guard; + + return { + ...(guard === undefined ? {} : { guard }), + items: itemsOf(content), + }; + }); + built.set(node, node.candidate.item(operands)); + if (node.candidate.authored !== undefined) { + drawn.add(node.candidate.authored); + } + } + + return { items: itemsOf(top), drawn }; +}; diff --git a/packages/core/graph/projection/src/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts index 5cb5536..b5d0fff 100644 --- a/packages/core/graph/projection/src/handoffs.ts +++ b/packages/core/graph/projection/src/handoffs.ts @@ -1,26 +1,58 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; +import { chainIndex } from "./chains.ts"; export interface Handoff { + /** The transition the hand-off starts with. */ readonly transition: I["transition"]; readonly from: I["actor"]; readonly to: I["actor"]; + /** The `message` elements the hand-off passes through, each once in the order met, when it is carried by a message; built when first read. */ + readonly through?: readonly I["element"][]; } /** - * Swimlane view: a transition between two elements with different actors is a hand-off. Elements without an actor never produce one. + * Swimlane view: a transition between two elements with different actors is a hand-off. A transition into a `message` element continues through it to the element after it, so a message between two steps is part of the hand-off it carries, not an end of it. Elements without an actor never produce one. + * @param model - The model to read. + * @returns The hand-offs, in the order of the transitions they start with. */ export const handoffs = ( model: Model, ): readonly Handoff[] => { const actorOf = actorMap(model); + const messages = new Set( + model.elements.flatMap((element) => + element.kind === "message" ? [element.id] : [], + ), + ); + const index = chainIndex(model, { + folded: (id) => messages.has(id), + accepted: (id) => actorOf.has(id), + }); const found: Handoff[] = []; for (const transition of model.transitions) { const from = actorOf.get(transition.source); - const to = actorOf.get(transition.target); - if (from !== undefined && to !== undefined && from !== to) { - found.push({ transition: transition.id, from, to }); + if (from === undefined) { + continue; + } + for (const chain of index.from(transition)) { + const to = actorOf.get(chain.target); + if (to === undefined || from === to) { + continue; + } + found.push( + transition.target === chain.target + ? { transition: transition.id, from, to } + : { + transition: transition.id, + from, + to, + get through(): readonly I["element"][] { + return chain.through(); + }, + }, + ); } } diff --git a/packages/core/graph/projection/src/index.ts b/packages/core/graph/projection/src/index.ts index 70e6b89..7430e41 100644 --- a/packages/core/graph/projection/src/index.ts +++ b/packages/core/graph/projection/src/index.ts @@ -1,5 +1,5 @@ /** - * Projections of the process model into each notation: hand-offs, flowchart, swimlane, state machine and sequence diagram. Each is a pure function of the model plus the extra input its notation needs (none, a subject entity, or a scenario of transition ids). + * Projections of the process model into each notation: hand-offs, flowchart, swimlane, state machine and sequence diagram. Each is a pure function of the model plus the extra input its notation needs (none, a subject entity, or a scenario of transition ids), and returns what it dropped. Each view also has an inverse that turns an edited view back into a typed diff against the model, applied with `applyModelDiff` once every inferred change is confirmed. */ export type { Loss, UnrepresentedElement, UnrepresentedKind } from "./loss.ts"; export { handoffs, type Handoff } from "./handoffs.ts"; @@ -23,3 +23,19 @@ export { type SequenceMessage, type SequenceOperand, } from "./sequence.ts"; +export { + applyModelDiff, + confirmChanges, + UnconfirmedChangeError, + type Addressing, + type Change, + type ElementChange, + type Ends, + type EventChange, + type ModelDiff, + type TransitionChange, + type TransitionFieldChange, +} from "./diff.ts"; +export { diffFlowchart, diffSwimlane } from "./inverse-flowchart.ts"; +export { diffStateMachine } from "./inverse-state-machine.ts"; +export { diffSequence } from "./inverse-sequence.ts"; diff --git a/packages/core/graph/projection/src/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts new file mode 100644 index 0000000..edcabb8 --- /dev/null +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -0,0 +1,395 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import type { ElementChange, ModelDiff, TransitionChange } from "./diff.ts"; +import { + flowchartParts, + type EdgeOrigin, + type FlowEdge, + type FlowNode, +} from "./flowchart.ts"; +import type { Swimlane } from "./swimlane.ts"; + +/** How many edited edges arrive at and leave each node, counted in one pass over the edges. */ +interface Degrees { + readonly in: ReadonlyMap; + readonly out: ReadonlyMap; +} + +const degreesOf = ( + edges: readonly FlowEdge[], +): Degrees => { + const incoming = new Map(); + const outgoing = new Map(); + for (const edge of edges) { + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoing.set(edge.from, (outgoing.get(edge.from) ?? 0) + 1); + } + + return { in: incoming, out: outgoing }; +}; + +/** What an added node becomes, with whether the inverse had to choose. A terminator is a start when nothing arrives at it and an end otherwise; a bar is a fork when it has more ways out than in and a join otherwise. */ +const addedElement = ( + node: FlowNode, + degrees: Degrees, + actor: I["actor"] | undefined, +): ElementChange => { + const owned = actor === undefined ? {} : { actor }; + if (node.shape === "process") { + return { + kind: "added", + element: { kind: "activity", id: node.id, label: node.label, ...owned }, + inferred: false, + }; + } + if (node.shape === "decision") { + return { + kind: "added", + element: { kind: "decision", id: node.id, label: node.label, ...owned }, + inferred: false, + }; + } + if (node.shape === "terminator") { + return { + kind: "added", + element: { + kind: "pseudo", + id: node.id, + role: (degrees.in.get(node.id) ?? 0) > 0 ? "final" : "initial", + }, + inferred: true, + }; + } + const ins = degrees.in.get(node.id) ?? 0; + const outs = degrees.out.get(node.id) ?? 0; + + return { + kind: "added", + element: { + kind: "pseudo", + id: node.id, + role: outs > ins ? "fork" : "join", + }, + inferred: true, + }; +}; + +/** + * The element and transition changes shared by the flowchart and swimlane inverses. `actorOf` gives the actor an edited node is placed with, for the swimlane; the flowchart has none and passes a function that always returns `undefined`. + */ +const diffFlow = ( + model: Model, + edited: Readonly<{ + nodes: readonly FlowNode[]; + edges: readonly FlowEdge[]; + }>, + actorOf: (node: I["element"]) => I["actor"] | undefined, +): Pick, "elements" | "transitions"> => { + const parts = flowchartParts(model); + const original = parts.flowchart; + const elements = new Map>( + model.elements.map((element) => [element.id, element]), + ); + const drawn = new Map>( + original.nodes.map((node) => [node.id, node]), + ); + const kept = new Set(edited.nodes.map((node) => node.id)); + const degrees = degreesOf(edited.edges); + + const elementChanges: ElementChange[] = []; + for (const node of original.nodes) { + if (!kept.has(node.id)) { + elementChanges.push({ + kind: "removed", + element: node.id, + inferred: false, + }); + } + } + for (const node of edited.nodes) { + const before = drawn.get(node.id); + if (before === undefined) { + if (elements.has(node.id)) { + throw new Error( + `Node "${node.id}" is a model element the flowchart does not draw`, + ); + } + elementChanges.push(addedElement(node, degrees, actorOf(node.id))); + continue; + } + if (before.shape !== node.shape) { + throw new Error( + `Node "${node.id}" changed shape from ${before.shape} to ${node.shape}; an element's kind is its identity, so remove it and add one under a new id`, + ); + } + if (before.label !== node.label) { + if (node.shape === "terminator" || node.shape === "bar") { + throw new Error( + `Node "${node.id}" is a ${node.shape}, whose label the projection supplies and the model does not hold`, + ); + } + elementChanges.push({ + kind: "renamed", + element: node.id, + from: before.label, + to: node.label, + inferred: false, + }); + } + } + + const transitionChanges: TransitionChange[] = []; + const transitions = new Map( + model.transitions.map((transition) => [transition.id, transition]), + ); + // Edges are matched by where they come from: the transition they start with and the node they end at, read from the projection's own record for the original edges, so an edge through messages is never built just to be compared. + const keyOf = (first: I["transition"], target: I["element"]): string => + JSON.stringify([first, target]); + const originals = new Map< + string, + { readonly edge: FlowEdge; readonly origin: EdgeOrigin } + >(); + for (const [position, origin] of parts.origins.entries()) { + const edge = original.edges[position]; + if (edge !== undefined) { + originals.set(keyOf(origin.first.id, origin.target), { edge, origin }); + } + } + /** An edited edge's key: its first transition and the node one of its transitions arrives at, or `undefined` for an edge whose first transition is new. */ + const editedKey = (edge: FlowEdge): string | undefined => { + const [first] = edge.transitions; + if (first === undefined || !transitions.has(first)) { + return undefined; + } + for (const id of edge.transitions) { + const target = transitions.get(id)?.target; + if (target !== undefined && drawn.has(target)) { + return keyOf(first, target); + } + } + + return undefined; + }; + const editedKeys = new Set(edited.edges.map(editedKey)); + // Omitted edges are grouped by how they end before anything is removed: an edge that is one transition removes it; edges that carry messages share their steps into an end, so each such end has its arriving steps removed once, however many of its edges were omitted, and which of them is a choice: inferred. + const removed = new Set(); + const endsLeft = new Set(); + for (const [key, { origin }] of originals) { + if (editedKeys.has(key)) { + continue; + } + if (origin.first.target !== origin.target) { + endsLeft.add(origin.target); + } else if (!removed.has(origin.first.id)) { + removed.add(origin.first.id); + transitionChanges.push({ + kind: "removed", + transition: origin.first.id, + inferred: false, + }); + } + } + for (const end of endsLeft) { + for (const transition of parts.index.arriving(end)) { + if (!removed.has(transition.id)) { + removed.add(transition.id); + transitionChanges.push({ + kind: "removed", + transition: transition.id, + inferred: true, + }); + } + } + } + // Where the edges into each end that carry messages were moved to, so the shared steps into that end are rewired once. + const movedEnds = new Map(); + for (const edge of edited.edges) { + const key = editedKey(edge); + const before = key === undefined ? undefined : originals.get(key); + const [first] = edge.transitions; + if (before === undefined) { + if ( + first === undefined || + edge.transitions.length > 1 || + transitions.has(first) || + edge.through !== undefined + ) { + throw new Error( + `Edge ${JSON.stringify(edge.transitions)} is neither an edge of the model nor a new edge with a single new transition id`, + ); + } + transitionChanges.push({ + kind: "added", + transition: { + id: first, + source: edge.from, + target: edge.to, + ...(edge.label === undefined ? {} : { guard: edge.label }), + }, + inferred: false, + }); + continue; + } + const { origin } = before; + const head = origin.first; + const chain = head.target !== origin.target; + if (!chain) { + if (before.edge.from !== edge.from || before.edge.to !== edge.to) { + transitionChanges.push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edge.from, target: edge.to }, + inferred: false, + }); + } + if (before.edge.label !== edge.label) { + transitionChanges.push({ + kind: "changed", + field: "guard", + transition: head.id, + from: head.guard, + to: edge.label, + inferred: false, + }); + } + continue; + } + if (before.edge.label !== edge.label) { + throw new Error( + `Edge ${JSON.stringify(edge.transitions)} carries a message; edit the message or its steps rather than the edge's label`, + ); + } + if (head.source !== edge.from) { + transitionChanges.push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edge.from, target: head.target }, + inferred: false, + }); + } + if (origin.target === edge.to) { + continue; + } + // The arriving steps are shared with every other edge through the same messages, so moving them is a choice: inferred, and made once per end however many of its edges moved. + const already = movedEnds.get(origin.target); + if (already === edge.to) { + continue; + } + if (already !== undefined) { + throw new Error( + `Edges ending at "${origin.target}" through the same steps were moved to different ends, "${already}" and "${edge.to}"`, + ); + } + movedEnds.set(origin.target, edge.to); + for (const tail of parts.index.arriving(origin.target)) { + transitionChanges.push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edge.to }, + inferred: true, + }); + } + } + + return { elements: elementChanges, transitions: transitionChanges }; +}; + +/** + * The inverse of `toFlowchart`: what an edited flowchart changes in the model, matched by element and transition id. + * + * A node not in the projection is an added element: a process an activity, a decision a decision, both certain; a terminator or bar a pseudo-state whose role is inferred from its edges. A projected node missing from the edit is removed, and a changed label renames the element. An edge is matched by where it comes from: the transition it starts with and the node one of its transitions arrives at. A new edge must carry one new transition id and is an added transition; a projected edge missing from the edit removes its transition; moved ends rewire it; a changed label changes its guard. An edge that carries messages stands for every route through them, so its earlier steps are shared with other edges: moving its source rewires its first transition, which is certain, while removing it or moving its target acts on each step from a message into its end, once however many edges share them, and is inferred. + * @param model - The model the flowchart was projected from. + * @param edited - The projected flowchart after editing. Its `loss` is not read. + * @returns The changes, with no event changes, since a flowchart draws no events. + * @throws Error when the edit cannot be expressed in the model: a node that is a model element the flowchart does not draw, a changed shape, a relabelled terminator or bar, an unmatched edge that is not a single new transition, a relabelled edge that carries a message, or edges sharing their last steps moved to different ends. + */ +export const diffFlowchart = ( + model: Model, + edited: Readonly<{ + nodes: readonly FlowNode[]; + edges: readonly FlowEdge[]; + }>, +): ModelDiff => ({ + ...diffFlow(model, edited, () => undefined), + events: [], +}); + +/** + * The inverse of `toSwimlane`: the flowchart changes plus the lane moves. A node is placed with the actor of the lane it is in, or with none when it is in `unassigned`; an activity or decision whose actor differs from the model's is relaned, and an added activity or decision takes its lane's actor. Hand-offs are derived and not read. + * @param model - The model the swimlane was projected from. + * @param edited - The projected swimlane after editing. + * @returns The changes; see `diffFlowchart` for nodes and edges. + * @throws Error in the cases `diffFlowchart` does, when a lane belongs to a participant that cannot act, or when a pseudo-state is put in a lane. + */ +export const diffSwimlane = ( + model: Model, + edited: Readonly, "nodes" | "edges" | "lanes">>, +): ModelDiff => { + const actors = new Set([ + ...model.participants.flatMap((participant) => + participant.kind === "entity" ? [] : [participant.id], + ), + ...model.elements.flatMap((element) => + (element.kind === "activity" || element.kind === "decision") && + element.actor !== undefined + ? [element.actor] + : [], + ), + ]); + const canAct = ( + participant: I["participant"] | I["actor"], + ): participant is I["actor"] => actors.has(participant); + + const placed = new Map(); + for (const lane of edited.lanes) { + if (!canAct(lane.participant)) { + throw new Error( + `Lane "${lane.participant}" belongs to a participant that cannot act`, + ); + } + for (const node of lane.nodes) { + placed.set(node, lane.participant); + } + } + const flow = diffFlow(model, edited, (node) => placed.get(node)); + + const relaned: ElementChange[] = []; + const shapes = new Map["shape"]>( + edited.nodes.map((node) => [node.id, node.shape]), + ); + for (const node of placed.keys()) { + const shape = shapes.get(node); + if (shape === "terminator" || shape === "bar") { + throw new Error( + `Node "${node}" is a pseudo-state and has no actor to put in a lane`, + ); + } + } + for (const element of model.elements) { + if ( + (element.kind !== "activity" && element.kind !== "decision") || + !shapes.has(element.id) + ) { + continue; + } + const to = placed.get(element.id); + if (to !== element.actor) { + relaned.push({ + kind: "relaned", + element: element.id, + from: element.actor, + to, + inferred: false, + }); + } + } + + return { + elements: [...flow.elements, ...relaned], + transitions: flow.transitions, + events: [], + }; +}; diff --git a/packages/core/graph/projection/src/inverse-sequence.ts b/packages/core/graph/projection/src/inverse-sequence.ts new file mode 100644 index 0000000..6a3fe25 --- /dev/null +++ b/packages/core/graph/projection/src/inverse-sequence.ts @@ -0,0 +1,214 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.ts"; +import { Occurrences } from "./occurrences.ts"; +import type { + ElementChange, + EventChange, + ModelDiff, + TransitionChange, +} from "./diff.ts"; +import { + toSequence, + type SequenceDiagram, + type SequenceMessage, +} from "./sequence.ts"; + +/** The changes in the order found, each once: a step a loop repeats yields the same change on every pass. */ +const distinct = (changes: readonly T[]): T[] => { + const seen = new Set(); + + return changes.filter((change) => { + const key = JSON.stringify(change); + if (seen.has(key)) { + return false; + } + seen.add(key); + + return true; + }); +}; + +/** + * The inverse of `toSequence`: what an edited sequence diagram changes in the model. A sequence diagram shows one path and leaves out who owns which step, so most of what it implies has to be inferred, and every such change is marked `inferred` until it is confirmed. + * + * Messages are matched to the projection by transition id, occurrence by occurrence, since a loop repeats a step. For a matched message: + * + * - a changed label renames what the label came from, which is certain: the `message` element it draws, the event that triggers the step, or the step's target element; + * - changed ends on a `message` element readdress it, which is certain; + * - a changed receiver relanes the step's target and a changed sender relanes its source, both inferred, since the edit could equally mean a different element. + * + * A projected message missing from the edit removes its transition, inferred, since it may only have left this scenario. A message with a new transition id is a new step, inferred: it must name the element it arrives at in `element` (an existing element, or a new activity owned by its receiver, labelled with the message), and it leaves from where the latest earlier message to its sender arrived. When the message after it was a step from that same place, that step is rewired to leave from the new element instead, inferred. Lifelines and items are derived from the messages and not read. + * @param model - The model the diagram was projected from. + * @param scenario - The scenario it was projected for. + * @param edited - The projected diagram after editing. + * @returns The changes, each listed once. + * @throws Error when the edit cannot be expressed in the model: a model transition added to the diagram that the scenario does not take (a scenario change, not a model change), a changed `element` on a drawn message, a changed sender on a step whose sender comes from an earlier step, or a new message with no `element` or with no earlier message to its sender. + */ +export const diffSequence = ( + model: Model, + scenario: readonly I["transition"][], + edited: Readonly, "messages">>, +): ModelDiff => { + const original = toSequence(model, scenario); + const transitions = new Map( + model.transitions.map((transition) => [transition.id, transition]), + ); + const events = new Map(model.events.map((event) => [event.id, event])); + const elementIds = new Set( + model.elements.map((element) => element.id), + ); + const actors = actorMap(model); + + const pending = new Occurrences( + original.messages, + (message: SequenceMessage) => message.transition, + ); + + const elementChanges: ElementChange[] = []; + const transitionChanges: TransitionChange[] = []; + const eventChanges: EventChange[] = []; + /** Where the latest edited message to each actor arrived, for placing a new step that actor sends. */ + const arrivals = new Map(); + /** A new step just placed, and where it left from, so the next drawn step can be rewired after it. */ + let inserted: + { readonly from: I["element"]; readonly element: I["element"] } | undefined; + + for (const message of edited.messages) { + const before = pending.next(message.transition); + const transition = transitions.get(message.transition); + if (before === undefined || transition === undefined) { + if (transition !== undefined) { + throw new Error( + `Transition "${message.transition}" is in the model but not on this scenario; a scenario change is not a model change`, + ); + } + const target = message.element; + if (target === undefined) { + throw new Error( + `New message "${message.transition}" must name the element it arrives at in \`element\``, + ); + } + const source = arrivals.get(message.from); + if (source === undefined) { + throw new Error( + `New message "${message.transition}" has no earlier message to "${message.from}" to leave from`, + ); + } + if (!elementIds.has(target)) { + elementChanges.push({ + kind: "added", + element: { + kind: "activity", + id: target, + label: message.label, + actor: message.to, + }, + inferred: true, + }); + } + transitionChanges.push({ + kind: "added", + transition: { id: message.transition, source, target }, + inferred: true, + }); + arrivals.set(message.to, target); + inserted = { from: source, element: target }; + continue; + } + + if (message.element !== before.element) { + throw new Error( + `Message "${message.transition}" draws "${String(before.element)}"; which element a drawn message is cannot be edited`, + ); + } + if (inserted?.from === transition.source) { + transitionChanges.push({ + kind: "rewired", + transition: transition.id, + from: { source: transition.source, target: transition.target }, + to: { source: inserted.element, target: transition.target }, + inferred: true, + }); + } + inserted = undefined; + arrivals.set(message.to, before.element ?? transition.target); + + if (before.label !== message.label) { + const trigger = + transition.trigger === undefined + ? undefined + : events.get(transition.trigger); + if (before.element !== undefined) { + elementChanges.push({ + kind: "renamed", + element: before.element, + from: before.label, + to: message.label, + inferred: false, + }); + } else if (trigger === undefined) { + elementChanges.push({ + kind: "renamed", + element: transition.target, + from: before.label, + to: message.label, + inferred: false, + }); + } else { + eventChanges.push({ + kind: "renamed", + event: trigger.id, + from: trigger.name, + to: message.label, + inferred: false, + }); + } + } + + if (before.element !== undefined) { + if (before.from !== message.from || before.to !== message.to) { + elementChanges.push({ + kind: "readdressed", + element: before.element, + from: { from: before.from, to: before.to }, + to: { from: message.from, to: message.to }, + inferred: false, + }); + } + continue; + } + if (before.to !== message.to) { + elementChanges.push({ + kind: "relaned", + element: transition.target, + from: before.to, + to: message.to, + inferred: true, + }); + } + if (before.from !== message.from) { + if (actors.get(transition.source) === undefined) { + throw new Error( + `The sender of "${message.transition}" is whoever reached "${transition.source}"; change the earlier step instead`, + ); + } + elementChanges.push({ + kind: "relaned", + element: transition.source, + from: before.from, + to: message.from, + inferred: true, + }); + } + } + + for (const transition of pending.unread()) { + transitionChanges.push({ kind: "removed", transition, inferred: true }); + } + + return { + elements: distinct(elementChanges), + transitions: distinct(transitionChanges), + events: distinct(eventChanges), + }; +}; diff --git a/packages/core/graph/projection/src/inverse-state-machine.ts b/packages/core/graph/projection/src/inverse-state-machine.ts new file mode 100644 index 0000000..1dcea80 --- /dev/null +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -0,0 +1,309 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import type { TransitionOf } from "./chains.ts"; +import type { ElementChange, ModelDiff, TransitionChange } from "./diff.ts"; +import type { EdgeOrigin } from "./flowchart.ts"; +import { + stateMachineParts, + type StateMachine, + type StateMachineState, + type StateMachineTransition, +} from "./state-machine.ts"; + +/** The element an added state becomes: an authored `state`, or an initial or final pseudo-state. */ +const addedState = ( + state: StateMachineState, +): ElementChange => ({ + kind: "added", + element: + state.role === "state" + ? { kind: "state", id: state.id, label: state.label } + : { kind: "pseudo", id: state.id, role: state.role }, + inferred: false, +}); + +/** + * The inverse of `toStateMachine`: what an edited state machine changes in the model, matched by element and transition id. + * + * A state not in the projection is an added element: a `state` element for a plain state, or an initial or final pseudo-state. A projected state missing from the edit is removed, and a changed label renames the activity or state behind it. + * + * A drawn transition is matched by where it comes from: its first model transition and the state one of its model transitions arrives at. One that stands for a single transition maps straight back: moved ends rewire it, and a changed event, guard or effect changes that transition's trigger, guard or effect. One that stands for a chain through folded elements (decisions, activities that do not affect the subject, messages) shares its later steps with every other chain through the same elements, so the inverse has to choose: moving its source rewires the first step and is certain, but moving its target, changing its labels or removing it acts on each step from a folded element into its target state, once however many drawn transitions share it, and is marked `inferred`. A new transition must carry one new transition id and is added with its event, guard and effect. + * @param model - The model the machine was projected from. + * @param edited - The projected machine after editing; `subject` says which projection it was. Its `loss` is not read. + * @returns The changes, with no event changes, since a state machine names events by id. + * @throws Error when the edit cannot be expressed in the model: a state that is a model element the machine does not draw, a changed role, a relabelled initial or final state, an unmatched transition that is not a single new transition, or a changed effect where the effect is the target activity's action. + */ +export const diffStateMachine = ( + model: Model, + edited: Readonly, "subject" | "states" | "transitions">>, +): ModelDiff => { + const parts = stateMachineParts(model, edited.subject); + const original = parts.machine; + const elements = new Map>( + model.elements.map((element) => [element.id, element]), + ); + const drawn = new Map>( + original.states.map((state) => [state.id, state]), + ); + const kept = new Set(edited.states.map((state) => state.id)); + + const elementChanges: ElementChange[] = []; + for (const state of original.states) { + if (!kept.has(state.id)) { + elementChanges.push({ + kind: "removed", + element: state.id, + inferred: false, + }); + } + } + for (const state of edited.states) { + const before = drawn.get(state.id); + if (before === undefined) { + if (elements.has(state.id)) { + throw new Error( + `State "${state.id}" is a model element the state machine does not draw`, + ); + } + elementChanges.push(addedState(state)); + continue; + } + if (before.role !== state.role) { + throw new Error( + `State "${state.id}" changed role from ${before.role} to ${state.role}; remove it and add one under a new id`, + ); + } + if (before.label !== state.label) { + if (state.role !== "state") { + throw new Error( + `State "${state.id}" is ${state.role === "initial" ? "an" : "a"} ${state.role} pseudo-state, whose label the projection supplies`, + ); + } + elementChanges.push({ + kind: "renamed", + element: state.id, + from: before.label, + to: state.label, + inferred: false, + }); + } + } + + const transitions = new Map( + model.transitions.map((transition) => [transition.id, transition]), + ); + // Drawn transitions are matched by where they come from: the transition they start with and the state they end at, read from the projection's own record for the original ones, so a transition through folded elements is never built just to be compared. + const keyOf = (first: I["transition"], target: I["element"]): string => + JSON.stringify([first, target]); + const originals = new Map< + string, + { + readonly drawn: StateMachineTransition; + readonly origin: EdgeOrigin; + } + >(); + for (const [position, origin] of parts.origins.entries()) { + const drawnTransition = original.transitions[position]; + if (drawnTransition !== undefined) { + originals.set(keyOf(origin.first.id, origin.target), { + drawn: drawnTransition, + origin, + }); + } + } + /** An edited transition's key: its first model transition and the state one of its model transitions arrives at, or `undefined` when its first transition is new. */ + const editedKey = (edit: StateMachineTransition): string | undefined => { + const [first] = edit.transitions; + if (first === undefined || !transitions.has(first)) { + return undefined; + } + for (const id of edit.transitions) { + const target = transitions.get(id)?.target; + if (target !== undefined && drawn.has(target)) { + return keyOf(first, target); + } + } + + return undefined; + }; + const editedKeys = new Set( + edited.transitions.map(editedKey), + ); + /** The model transitions arriving at a drawn transition's state: its one transition, or every step from a folded element into that state. */ + const arriving = (origin: EdgeOrigin): readonly TransitionOf[] => + origin.first.target === origin.target + ? [origin.first] + : parts.index.arriving(origin.target); + const transitionChanges: TransitionChange[] = []; + // A step from a folded element is shared by every chain through it, so a change to it is made once however many drawn transitions ask for it. + const made = new Set(); + const push = (change: TransitionChange): void => { + const key = JSON.stringify(change); + if (!made.has(key)) { + made.add(key); + transitionChanges.push(change); + } + }; + // Omitted transitions are grouped by how they end first: one that is a single model transition removes it; ones through folded elements share their steps into a state, so each such state has its arriving steps removed once. + const endsLeft = new Set(); + for (const [key, { origin }] of originals) { + if (editedKeys.has(key)) { + continue; + } + if (origin.first.target === origin.target) { + push({ kind: "removed", transition: origin.first.id, inferred: false }); + } else { + endsLeft.add(origin.target); + } + } + for (const end of endsLeft) { + for (const tail of parts.index.arriving(end)) { + push({ kind: "removed", transition: tail.id, inferred: true }); + } + } + // An edit to a transition through folded elements acts on the steps into its state, which every such transition into that state shares: each kind of edit is made once per state, and edits to the same state that disagree are refused. + const editedEnds = new Map(); + const firstAtEnd = ( + end: I["element"], + kind: string, + value: unknown, + ): boolean => { + const key = JSON.stringify([end, kind]); + // `JSON.stringify` gives no text for `undefined`, which here means the label was cleared. + const wanted = value === undefined ? "" : `=${JSON.stringify(value)}`; + const already = editedEnds.get(key); + if (already === undefined) { + editedEnds.set(key, wanted); + + return true; + } + if (already !== wanted) { + throw new Error( + `Transitions into "${end}" through the same steps were edited differently (${kind})`, + ); + } + + return false; + }; + for (const edit of edited.transitions) { + const key = editedKey(edit); + const before = key === undefined ? undefined : originals.get(key); + const [first] = edit.transitions; + if (before === undefined) { + if ( + first === undefined || + edit.transitions.length > 1 || + transitions.has(first) || + edit.through !== undefined + ) { + throw new Error( + `Transition ${JSON.stringify(edit.transitions)} is neither a transition of the model nor a new transition with a single new id`, + ); + } + push({ + kind: "added", + transition: { + id: first, + source: edit.source, + target: edit.target, + ...(edit.event === undefined ? {} : { trigger: edit.event }), + ...(edit.guard === undefined ? {} : { guard: edit.guard }), + ...(edit.effect === undefined ? {} : { effect: edit.effect }), + }, + inferred: false, + }); + continue; + } + const { origin, drawn: previous } = before; + const head = origin.first; + const chain = head.target !== origin.target; + const tails = arriving(origin); + /** The arriving steps to act on for one kind of edit: the transition itself, or the shared steps into the state the first time that kind of edit reaches it. */ + const acting = ( + kind: string, + value: unknown, + ): readonly TransitionOf[] => + !chain || firstAtEnd(origin.target, kind, value) ? tails : []; + if ( + !chain && + (previous.source !== edit.source || previous.target !== edit.target) + ) { + push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edit.source, target: edit.target }, + inferred: false, + }); + } else if (chain) { + if (previous.source !== edit.source) { + push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edit.source, target: head.target }, + inferred: false, + }); + } + for (const tail of previous.target === edit.target + ? [] + : acting("target", edit.target)) { + push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edit.target }, + inferred: true, + }); + } + } + for (const tail of previous.event === edit.event + ? [] + : acting("trigger", edit.event)) { + push({ + kind: "changed", + field: "trigger", + transition: tail.id, + from: tail.trigger, + to: edit.event, + inferred: chain, + }); + } + for (const tail of previous.guard === edit.guard + ? [] + : acting("guard", edit.guard)) { + push({ + kind: "changed", + field: "guard", + transition: tail.id, + from: tail.guard, + to: edit.guard, + inferred: chain, + }); + } + if (previous.effect !== edit.effect) { + const target = elements.get(previous.target); + if (target?.kind === "activity" && target.action !== undefined) { + throw new Error( + `The effect of ${JSON.stringify(edit.transitions)} is the action of "${target.id}"; change that activity's action instead`, + ); + } + for (const tail of acting("effect", edit.effect)) { + push({ + kind: "changed", + field: "effect", + transition: tail.id, + from: tail.effect, + to: edit.effect, + inferred: chain, + }); + } + } + } + + return { + elements: elementChanges, + transitions: transitionChanges, + events: [], + }; +}; diff --git a/packages/core/graph/projection/src/inverse.unit.test.ts b/packages/core/graph/projection/src/inverse.unit.test.ts new file mode 100644 index 0000000..06d2ec3 --- /dev/null +++ b/packages/core/graph/projection/src/inverse.unit.test.ts @@ -0,0 +1,508 @@ +import { + validate, + type Model, + type OpenIds, +} from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { + applyModelDiff, + confirmChanges, + diffFlowchart, + diffSequence, + diffStateMachine, + diffSwimlane, + handoffs, + toFlowchart, + toSequence, + toStateMachine, + toSwimlane, + UnconfirmedChangeError, + type ModelDiff, + type SequenceMessage, +} from "./index.ts"; +import { order } from "./test-support/order.ts"; +import { orderWithMessages } from "./test-support/order-variants.ts"; + +/** The order process in the open identifier space, so an edit can introduce identifiers it does not declare. */ +const model: Model = { ...order }; +const shipped = ["t1", "t2", "t3", "t4", "t5", "t7"]; + +const confirmAll = (diff: ModelDiff): ModelDiff => + confirmChanges(diff, () => true); + +describe("diffFlowchart on the order process", () => { + const flow = toFlowchart(model); + const edited = { + nodes: [ + ...flow.nodes + .filter((node) => node.id !== "a-cancel") + .map((node) => + node.id === "a-pack" ? { ...node, label: "Pack and dispatch" } : node, + ), + { id: "a-gift", label: "Gift wrap", shape: "process" as const }, + ], + edges: [ + ...flow.edges + .filter( + (edge) => + !edge.transitions.includes("t6") && + !edge.transitions.includes("t8"), + ) + .map((edge) => { + if (edge.transitions.includes("t7")) { + return { ...edge, from: "a-gift" }; + } + + return edge.transitions.includes("t5") + ? { ...edge, label: "approved" } + : edge; + }), + { from: "a-pack", to: "a-gift", transitions: ["t9"] }, + ], + }; + + it("reports removed, renamed and added steps and the rewired, relabelled, removed and added transitions", () => { + expect(diffFlowchart(model, edited)).toEqual({ + elements: [ + { kind: "removed", element: "a-cancel", inferred: false }, + { + kind: "renamed", + element: "a-pack", + from: "Pack and ship", + to: "Pack and dispatch", + inferred: false, + }, + { + kind: "added", + element: { kind: "activity", id: "a-gift", label: "Gift wrap" }, + inferred: false, + }, + ], + transitions: [ + { kind: "removed", transition: "t6", inferred: false }, + { kind: "removed", transition: "t8", inferred: false }, + { + kind: "changed", + field: "guard", + transition: "t5", + from: "authorised", + to: "approved", + inferred: false, + }, + { + kind: "rewired", + transition: "t7", + from: { source: "a-pack", target: "end" }, + to: { source: "a-gift", target: "end" }, + inferred: false, + }, + { + kind: "added", + transition: { id: "t9", source: "a-pack", target: "a-gift" }, + inferred: false, + }, + ], + events: [], + }); + }); + + it("applies to a valid model whose flowchart is the edited one", () => { + const applied = applyModelDiff(model, diffFlowchart(model, edited)); + + expect(validate(applied)).toEqual([]); + expect(toFlowchart(applied).nodes).toEqual(edited.nodes); + expect(toFlowchart(applied).edges).toEqual( + expect.arrayContaining(edited.edges), + ); + expect(toFlowchart(applied).edges).toHaveLength(edited.edges.length); + }); + + it("infers a new terminator's role and holds it back until confirmed", () => { + const diff = diffFlowchart(model, { + nodes: [ + ...flow.nodes, + { id: "abandoned", label: "End", shape: "terminator" }, + ], + edges: [ + ...flow.edges, + { from: "a-cancel", to: "abandoned", transitions: ["t9"] }, + ], + }); + + expect(diff.elements).toEqual([ + { + kind: "added", + element: { kind: "pseudo", id: "abandoned", role: "final" }, + inferred: true, + }, + ]); + expect(() => applyModelDiff(model, diff)).toThrow(UnconfirmedChangeError); + expect(applyModelDiff(model, confirmAll(diff)).elements.at(-1)).toEqual({ + kind: "pseudo", + id: "abandoned", + role: "final", + }); + }); + + it("refuses a change of shape, which would change the element's kind", () => { + expect(() => + diffFlowchart(model, { + nodes: flow.nodes.map((node) => + node.id === "a-pack" ? { ...node, shape: "decision" as const } : node, + ), + edges: flow.edges, + }), + ).toThrow(/changed shape from process to decision/u); + }); +}); + +describe("diffSwimlane on the order process", () => { + const lanes = toSwimlane(model); + + it("relanes a step moved to another actor's lane, making the cancellation a hand-off to the customer", () => { + const edited = { + ...lanes, + lanes: lanes.lanes.map((lane) => { + if (lane.participant === "shop") { + return { + ...lane, + nodes: lane.nodes.filter((id) => id !== "a-cancel"), + }; + } + + return lane.participant === "customer" + ? { ...lane, nodes: [...lane.nodes, "a-cancel"] } + : lane; + }), + }; + const diff = diffSwimlane(model, edited); + + expect(diff).toEqual({ + elements: [ + { + kind: "relaned", + element: "a-cancel", + from: "shop", + to: "customer", + inferred: false, + }, + ], + transitions: [], + events: [], + }); + expect(handoffs(applyModelDiff(model, diff))).toContainEqual({ + transition: "t6", + from: "shop", + to: "customer", + }); + }); + + it("gives a step added to a lane that lane's actor", () => { + const diff = diffSwimlane(model, { + ...lanes, + nodes: [ + ...lanes.nodes, + { id: "a-refund", label: "Refund", shape: "process" }, + ], + lanes: lanes.lanes.map((lane) => + lane.participant === "provider" + ? { ...lane, nodes: [...lane.nodes, "a-refund"] } + : lane, + ), + }); + + expect(diff.elements).toEqual([ + { + kind: "added", + element: { + kind: "activity", + id: "a-refund", + label: "Refund", + actor: "provider", + }, + inferred: false, + }, + ]); + }); + + it("refuses a lane for an entity, which cannot act", () => { + expect(() => + diffSwimlane(model, { + ...lanes, + lanes: [ + ...lanes.lanes, + { participant: "order", label: "Order", nodes: [] }, + ], + }), + ).toThrow(/cannot act/u); + }); +}); + +describe("diffStateMachine on the order's state machine", () => { + const machine = toStateMachine(model, "order"); + const edited = { + subject: "order", + states: [ + ...machine.states.map((state) => + state.id === "a-pack" ? { ...state, label: "Packed" } : state, + ), + { id: "s-delivered", label: "Delivered", role: "state" as const }, + ], + transitions: [ + ...machine.transitions + .filter((transition) => transition.target !== "a-cancel") + .map((transition) => + transition.target === "a-pack" + ? { ...transition, guard: "approved" } + : transition, + ), + { + source: "a-pack", + target: "s-delivered", + event: "ev-authorised", + transitions: ["t9"], + }, + ], + }; + + it("maps single transitions back with certainty and marks what acts on a folded decision's branch as inferred", () => { + expect(diffStateMachine(model, edited)).toEqual({ + elements: [ + { + kind: "renamed", + element: "a-pack", + from: "Pack and ship", + to: "Packed", + inferred: false, + }, + { + kind: "added", + element: { kind: "state", id: "s-delivered", label: "Delivered" }, + inferred: false, + }, + ], + transitions: [ + { kind: "removed", transition: "t6", inferred: true }, + { + kind: "changed", + field: "guard", + transition: "t5", + from: "authorised", + to: "approved", + inferred: true, + }, + { + kind: "added", + transition: { + id: "t9", + source: "a-pack", + target: "s-delivered", + trigger: "ev-authorised", + }, + inferred: false, + }, + ], + events: [], + }); + }); + + it("applies once confirmed, giving the edited machine", () => { + const diff = diffStateMachine(model, edited); + + expect(() => applyModelDiff(model, diff)).toThrow(UnconfirmedChangeError); + + const applied = applyModelDiff(model, confirmAll(diff)); + const projected = toStateMachine(applied, "order"); + + expect(projected.states).toEqual(edited.states); + expect(projected.transitions).toContainEqual({ + source: "a-authorise", + target: "a-pack", + event: "ev-authorised", + guard: "approved", + effect: "act-pack", + transitions: ["t4", "t5"], + through: ["d-authorised"], + }); + expect( + projected.transitions.map((transition) => transition.target), + ).not.toContain("a-cancel"); + }); + + it("refuses an effect edit where the effect is the target activity's action", () => { + expect(() => + diffStateMachine(model, { + ...machine, + transitions: machine.transitions.map((transition) => + transition.target === "a-request" + ? { ...transition, effect: "act-pack" } + : transition, + ), + }), + ).toThrow(/action of "a-request"/u); + }); +}); + +describe("diffSequence on the shipped path", () => { + const diagram = toSequence(model, shipped); + + it("infers a new step from an inserted message and rewires the step after it, and renames what labels came from with certainty", () => { + const edited = { + messages: diagram.messages.flatMap((message) => { + if (message.transition === "t2") { + return [ + message, + { + transition: "tq", + from: "shop", + to: "shop", + label: "Check stock", + element: "a-stock", + }, + ]; + } + if (message.transition === "t4") { + return [{ ...message, label: "Authorisation result" }]; + } + + return message.transition === "t5" + ? [{ ...message, label: "payment approved" }] + : [message]; + }), + }; + const diff = diffSequence(model, shipped, edited); + + expect(diff).toEqual({ + elements: [ + { + kind: "added", + element: { + kind: "activity", + id: "a-stock", + label: "Check stock", + actor: "shop", + }, + inferred: true, + }, + { + kind: "renamed", + element: "d-authorised", + from: "Payment authorised?", + to: "Authorisation result", + inferred: false, + }, + ], + transitions: [ + { + kind: "added", + transition: { id: "tq", source: "a-request", target: "a-stock" }, + inferred: true, + }, + { + kind: "rewired", + transition: "t3", + from: { source: "a-request", target: "a-authorise" }, + to: { source: "a-stock", target: "a-authorise" }, + inferred: true, + }, + ], + events: [ + { + kind: "renamed", + event: "ev-authorised", + from: "payment authorised", + to: "payment approved", + inferred: false, + }, + ], + }); + expect(() => applyModelDiff(model, diff)).toThrow(UnconfirmedChangeError); + + const applied = applyModelDiff(model, confirmAll(diff)); + + expect(validate(applied)).toEqual([]); + // The new step arrives at an activity, so the projection names no message element for it. + const drawn = ({ + transition, + from, + to, + label, + }: SequenceMessage): readonly string[] => [ + transition, + from, + to, + label, + ]; + + expect( + toSequence(applied, [ + "t1", + "t2", + "tq", + "t3", + "t4", + "t5", + "t7", + ]).messages.map(drawn), + ).toEqual(edited.messages.map(drawn)); + }); + + it("infers that a dropped message removes its step and a moved receiver relanes its target", () => { + const diff = diffSequence(model, shipped, { + messages: diagram.messages + .filter((message) => message.transition !== "t5") + .map((message) => + message.transition === "t2" + ? { ...message, to: "provider" } + : message, + ), + }); + + expect(diff).toEqual({ + elements: [ + { + kind: "relaned", + element: "a-request", + from: "shop", + to: "provider", + inferred: true, + }, + ], + transitions: [{ kind: "removed", transition: "t5", inferred: true }], + events: [], + }); + }); + + it("readdresses a message element with certainty", () => { + const path = ["t1", "t2", "t3a", "t3b", "t4", "t5", "t7a", "t7b"] as const; + const withMessages = toSequence(orderWithMessages, path); + const diff = diffSequence(orderWithMessages, path, { + messages: withMessages.messages.map((message) => + message.element === "m-dispatch" + ? { ...message, from: "provider" } + : message, + ), + }); + + expect(diff.elements).toEqual([ + { + kind: "readdressed", + element: "m-dispatch", + from: { from: "shop", to: "customer" }, + to: { from: "provider", to: "customer" }, + inferred: false, + }, + ]); + }); + + it("refuses a new message that does not name the element it arrives at", () => { + expect(() => + diffSequence(model, shipped, { + messages: [ + ...diagram.messages, + { transition: "tx", from: "shop", to: "customer", label: "Thanks" }, + ], + }), + ).toThrow(/must name the element/u); + }); +}); diff --git a/packages/core/graph/projection/src/loss.ts b/packages/core/graph/projection/src/loss.ts index d8fdbdc..ae6da09 100644 --- a/packages/core/graph/projection/src/loss.ts +++ b/packages/core/graph/projection/src/loss.ts @@ -5,8 +5,9 @@ import type { PseudoRole, } from "@exadev/process-graph-schema"; -/** What an element that a notation cannot draw is: an element kind, or the role of a pseudo-state. */ -export type UnrepresentedKind = "activity" | "decision" | "state" | PseudoRole; +/** What an element that a notation cannot draw is: an element kind, or the role of a pseudo-state. A `message` element is one only when the notation would fold it but folding it would multiply edges. */ +export type UnrepresentedKind = + "activity" | "decision" | "state" | "message" | PseudoRole; /** An element a projection could not draw, and what it was. */ export interface UnrepresentedElement { @@ -22,7 +23,7 @@ export type Loss = | { readonly kind: "layout"; readonly elements: readonly I["element"][] } /** Elements carrying notation-only `extensions`, which no projection shows. */ | { readonly kind: "extensions"; readonly elements: readonly I["element"][] } - /** `message` elements, which the projection does not draw. */ + /** `message` elements the projection neither draws nor folds into an edge or transition. */ | { readonly kind: "message-elements"; readonly elements: readonly I["element"][]; @@ -59,7 +60,7 @@ export type Loss = }; /** - * The elements the notation cannot draw: every element that is neither in `accounted` (drawn, or folded away by design, as a decision is in a state machine) nor a `message`, which is reported as `message-elements` by every projection. + * The elements the notation cannot draw: every element that is neither in `accounted` (drawn, or folded away by design, as a decision is in a state machine) nor a `message`, which `commonLoss` reports as `message-elements` when it is not shown. * @param model - The model being projected. * @param notation - The notation that cannot draw them. * @param accounted - Elements the projection draws or folds away by design. @@ -69,10 +70,12 @@ export const unrepresentedLoss = ( model: Model, notation: Notation, accounted: ReadonlySet, + unfolded: ReadonlySet, ): readonly Loss[] => { const elements: UnrepresentedElement[] = []; for (const element of model.elements) { - if (element.kind === "message" || accounted.has(element.id)) { + const message = element.kind === "message" && !unfolded.has(element.id); + if (message || accounted.has(element.id)) { continue; } elements.push({ @@ -95,11 +98,24 @@ export const unrepresentedLoss = ( return [{ kind: "unrepresented-elements", notation, elements, transitions }]; }; -/** The losses every projection can have, whatever its notation: dropped layout, notation-only extensions, message elements and authored fragments. */ +/** What a projection drew of the elements and fragments that only some notations can show. */ +export interface Shown { + /** Authored fragments drawn. */ + readonly fragments: ReadonlySet; + /** `message` elements drawn, or folded into an edge or transition that carries them. */ + readonly messages: ReadonlySet; +} + +/** + * The losses every projection can have, whatever its notation: dropped layout, notation-only extensions, message elements and authored fragments. + * @param model - The model projected. + * @param notation - The notation whose recorded positions are dropped. + * @param shown - The message elements and fragments the projection did draw, which are therefore not reported. + */ export const commonLoss = ( model: Model, notation: Notation, - shownFragments: ReadonlySet = new Set(), + shown: Shown, ): readonly Loss[] => { const positions = model.views?.[notation]; const positioned = model.elements.filter( @@ -111,10 +127,10 @@ export const commonLoss = ( Object.keys(element.extensions).length > 0, ); const messages = model.elements.filter( - (element) => element.kind === "message", + (element) => element.kind === "message" && !shown.messages.has(element.id), ); const hidden = model.fragments.filter( - (fragment) => !shownFragments.has(fragment.id), + (fragment) => !shown.fragments.has(fragment.id), ); const losses: Loss[] = []; diff --git a/packages/core/graph/projection/src/loss.unit.test.ts b/packages/core/graph/projection/src/loss.unit.test.ts index 6442fb8..a498fc6 100644 --- a/packages/core/graph/projection/src/loss.unit.test.ts +++ b/packages/core/graph/projection/src/loss.unit.test.ts @@ -4,6 +4,10 @@ import { toFlowchart } from "./flowchart.ts"; import { toSequence } from "./sequence.ts"; import { toStateMachine } from "./state-machine.ts"; import { toSwimlane } from "./swimlane.ts"; +import { fanThroughShape } from "./test-support/shapes.ts"; + +/** The smallest fan where folding multiplies: three times three exceeds three plus three. */ +const fanned = 3; /** Carries every kind of information a projection can drop: positions, extensions, a message element, a fragment, and an activity that does not affect the subject. */ const rich = defineModel({ @@ -260,4 +264,25 @@ describe("loss reports", () => { { kind: "skipped-steps", transitions: ["t1"] }, ]); }); + + it("reports a message whose folding would multiply edges as unrepresented, with its transitions", () => { + // Three sources through one message to three ends: folding would draw nine edges where the model has six transitions. + const { model } = fanThroughShape(1)(fanned); + + expect(toFlowchart(model).edges).toEqual([]); + expect(toFlowchart(model).loss).toEqual([ + { + kind: "unrepresented-elements", + notation: "flowchart", + elements: [{ element: "m-0", kind: "message" }], + transitions: ["t-s-0", "t-s-1", "t-s-2", "t-e-0", "t-e-1", "t-e-2"], + }, + ]); + expect(toStateMachine(model, "order").loss).toContainEqual({ + kind: "unrepresented-elements", + notation: "stateMachine", + elements: [{ element: "m-0", kind: "message" }], + transitions: ["t-s-0", "t-s-1", "t-s-2", "t-e-0", "t-e-1", "t-e-2"], + }); + }); }); diff --git a/packages/core/graph/projection/src/occurrences.ts b/packages/core/graph/projection/src/occurrences.ts new file mode 100644 index 0000000..197150e --- /dev/null +++ b/packages/core/graph/projection/src/occurrences.ts @@ -0,0 +1,55 @@ +/** + * Items grouped by key, each group kept in its original order and read off one at a time: the projected messages of a sequence diagram grouped by transition, matched against an edited diagram occurrence by occurrence, since a loop repeats a step. + * + * Storing an item appends it to its group and reading one advances a position in that group, so building and draining the groups touches each item a constant number of times however often a key repeats. + */ +export class Occurrences { + readonly #groups = new Map(); + + /** + * @param items - The items, in order. + * @param keyOf - The key an item is grouped by. + * @param observe - Told of each item as it is stored, so a caller can measure the work done. + */ + constructor( + items: Readonly>, + keyOf: (item: Item) => Key, + observe?: (item: Item) => void, + ) { + for (const item of items) { + observe?.(item); + const key = keyOf(item); + const group = this.#groups.get(key); + if (group === undefined) { + this.#groups.set(key, { items: [item], read: 0 }); + } else { + group.items.push(item); + } + } + } + + /** + * The next unread item of a key's group, which is then read. + * @param key - The group to read from. + * @returns The item, or `undefined` when the key has no group or every item in it has been read. + */ + next(key: Key): Item | undefined { + const group = this.#groups.get(key); + const item = group?.items[group.read]; + if (group !== undefined && item !== undefined) { + group.read += 1; + } + + return item; + } + + /** + * The keys with items still unread, in the order their groups were first stored. + * @returns The keys. + */ + unread(): Key[] { + return [...this.#groups] + .filter(([, group]) => group.read < group.items.length) + .map(([key]) => key); + } +} diff --git a/packages/core/graph/projection/src/occurrences.unit.test.ts b/packages/core/graph/projection/src/occurrences.unit.test.ts new file mode 100644 index 0000000..4899924 --- /dev/null +++ b/packages/core/graph/projection/src/occurrences.unit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { Occurrences } from "./occurrences.ts"; + +/** Enough repeats of one key that copying its group on every store would be far more work than storing each item once. */ +const repeats = 5000; + +describe("Occurrences", () => { + const items = [ + ...Array.from({ length: repeats }, (_, index) => ({ key: "loop", index })), + { key: "exit", index: repeats }, + ]; + + it("stores each item once however often its key repeats", () => { + let stored = 0; + new Occurrences( + items, + (item) => item.key, + () => { + stored += 1; + }, + ); + + expect(stored).toBe(items.length); + }); + + it("reads each key's items back in order and reports the keys left unread", () => { + const groups = new Occurrences(items, (item) => item.key); + + expect(groups.next("loop")).toEqual({ key: "loop", index: 0 }); + expect(groups.next("loop")).toEqual({ key: "loop", index: 1 }); + expect(groups.next("missing")).toBeUndefined(); + expect(groups.unread()).toEqual(["loop", "exit"]); + expect(groups.next("exit")).toEqual({ key: "exit", index: repeats }); + expect(groups.next("exit")).toBeUndefined(); + expect(groups.unread()).toEqual(["loop"]); + }); +}); diff --git a/packages/core/graph/projection/src/order-views.unit.test.ts b/packages/core/graph/projection/src/order-views.unit.test.ts new file mode 100644 index 0000000..f19a026 --- /dev/null +++ b/packages/core/graph/projection/src/order-views.unit.test.ts @@ -0,0 +1,410 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { + handoffs, + toFlowchart, + toSequence, + toStateMachine, + toSwimlane, + type SequenceItem, + type SequenceMessage, +} from "./index.ts"; +import { order } from "./test-support/order.ts"; +import { + orderWithFulfilment, + orderWithMessages, + orderWithRetry, +} from "./test-support/order-variants.ts"; + +const message = ( + transition: string, + from: string, + to: string, + label: string, +): SequenceItem => ({ + kind: "message", + message: { transition, from, to, label }, +}); + +const request = message("t2", "customer", "shop", "Request payment"); +const authorise = message("t3", "shop", "provider", "Authorise or decline"); +const decide = message("t4", "provider", "shop", "Payment authorised?"); + +describe("sequence fragments derived from the order process's structure", () => { + it("wraps the shipped path's authorised branch in an alt from the decision", () => { + const diagram = toSequence(order, ["t1", "t2", "t3", "t4", "t5", "t7"]); + + expect(diagram.items).toEqual([ + request, + authorise, + decide, + { + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [message("t5", "shop", "shop", "payment authorised")], + }, + ], + }, + ]); + }); + + it("wraps the cancelled path's declined branch in the same decision's alt", () => { + const diagram = toSequence(order, ["t1", "t2", "t3", "t4", "t6", "t8"]); + + expect(diagram.items.at(-1)).toEqual({ + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "declined", + items: [message("t6", "shop", "shop", "payment declined")], + }, + ], + }); + }); + + it("wraps a retried payment in a loop guarded by the way back, then the alt for the way out", () => { + const diagram = toSequence(orderWithRetry, [ + "t1", + "t2", + "t3", + "t4", + "t6", + "t9", + "t3", + "t4", + "t5", + "t7", + ]); + + expect(diagram.items).toEqual([ + { + kind: "fragment", + element: "d-authorised", + operator: "loop", + operands: [ + { + guard: "declined", + items: [ + request, + authorise, + decide, + message("t6", "shop", "customer", "payment declined"), + message("t9", "customer", "shop", "Request payment"), + authorise, + decide, + ], + }, + ], + }, + { + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [message("t5", "shop", "shop", "payment authorised")], + }, + ], + }, + ]); + }); + + it("wraps parallel fulfilment in a par from the fork and an optional step in an opt, inside the authorised branch", () => { + const diagram = toSequence(orderWithFulfilment, [ + "t1", + "t2", + "t3", + "t4", + "t5", + "tf1", + "tj1", + "tf2", + "tj2", + "tg", + "tw", + "tw2", + ]); + + expect(diagram.items).toEqual([ + request, + authorise, + decide, + { + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [ + { + kind: "fragment", + element: "fork", + operator: "par", + operands: [ + { items: [message("tf1", "shop", "shop", "Pack and ship")] }, + { + items: [message("tf2", "shop", "provider", "Send receipt")], + }, + ], + }, + message("tg", "provider", "shop", "Gift wrap?"), + { + kind: "fragment", + element: "d-gift", + operator: "opt", + operands: [ + { + guard: "gift", + items: [message("tw", "shop", "shop", "Wrap gift")], + }, + ], + }, + ], + }, + ], + }, + ]); + expect(diagram.loss).toContainEqual({ + kind: "skipped-steps", + transitions: ["t1", "t5", "tj1", "tj2", "tw2"], + }); + }); + + it("does not draw a derived fragment that holds exactly the messages of an authored one", () => { + const authored: Model = { + ...order, + fragments: [ + { + id: "f-outcome", + operator: "alt", + operands: [ + { guard: "paid", body: ["a-pack"] }, + { guard: "unpaid", body: ["a-cancel"] }, + ], + }, + ], + }; + + expect( + toSequence(authored, ["t1", "t2", "t3", "t4", "t5", "t7"]).items.at(-1), + ).toEqual({ + kind: "fragment", + fragment: "f-outcome", + operator: "alt", + operands: [ + { + guard: "paid", + items: [message("t5", "shop", "shop", "payment authorised")], + }, + ], + }); + }); +}); + +describe("message elements in the order process", () => { + const shipped = ["t1", "t2", "t3a", "t3b", "t4", "t5", "t7a", "t7b"] as const; + + it("draws a message element on the sequence path with its own ends, label and mode", () => { + const diagram = toSequence(orderWithMessages, shipped); + const charge: SequenceMessage = { + transition: "t3a", + from: "shop", + to: "provider", + label: "Charge card", + element: "m-charge", + mode: "sync", + }; + const dispatch: SequenceMessage = { + transition: "t7a", + from: "shop", + to: "customer", + label: "Dispatch notice", + element: "m-dispatch", + mode: "async", + }; + + expect(diagram.messages).toEqual([ + { + transition: "t2", + from: "customer", + to: "shop", + label: "Request payment", + }, + charge, + { + transition: "t4", + from: "provider", + to: "shop", + label: "Payment authorised?", + }, + { + transition: "t5", + from: "shop", + to: "shop", + label: "payment authorised", + }, + dispatch, + ]); + expect(diagram.items.at(-1)).toEqual({ + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [ + message("t5", "shop", "shop", "payment authorised"), + { kind: "message", message: dispatch }, + ], + }, + ], + }); + expect(diagram.loss).toEqual([ + { kind: "scenario", elements: ["a-cancel"] }, + { kind: "unshown-branches", transitions: ["t6"] }, + { kind: "skipped-steps", transitions: ["t1"] }, + ]); + }); + + it("carries a message element on the flowchart edge between the steps it connects", () => { + const flow = toFlowchart(orderWithMessages); + + expect(flow.nodes.map((node) => node.id)).not.toContain("m-charge"); + expect(flow.edges).toContainEqual({ + from: "a-request", + to: "a-authorise", + label: "Charge card", + transitions: ["t3a", "t3b"], + through: ["m-charge"], + }); + expect(flow.edges).toContainEqual({ + from: "a-pack", + to: "end", + label: "Dispatch notice", + transitions: ["t7a", "t7b"], + through: ["m-dispatch"], + }); + expect(flow.loss).toEqual([]); + }); + + it("keeps the payment request a hand-off from shop to provider when a message carries it", () => { + expect(handoffs(orderWithMessages)).toEqual([ + { transition: "t2", from: "customer", to: "shop" }, + { + transition: "t3a", + from: "shop", + to: "provider", + through: ["m-charge"], + }, + { transition: "t4", from: "provider", to: "shop" }, + ]); + expect(toSwimlane(orderWithMessages).loss).toEqual([]); + }); + + it("folds a message element into the state machine transition it carries, triggered by its event", () => { + const machine = toStateMachine(orderWithMessages, "order"); + + expect(machine.transitions).toContainEqual({ + source: "a-request", + target: "a-authorise", + event: "ev-charge", + transitions: ["t3a", "t3b"], + through: ["m-charge"], + }); + expect(machine.transitions).toContainEqual({ + source: "a-pack", + target: "end", + transitions: ["t7a", "t7b"], + through: ["m-dispatch"], + }); + expect(machine.loss).toEqual([]); + }); + + it("still reports a message element that no step reaches", () => { + const stray: Model = { + ...orderWithMessages, + elements: [ + ...orderWithMessages.elements, + { + kind: "message", + id: "m-survey", + label: "Survey", + from: "shop", + to: "customer", + mode: "async", + }, + ], + }; + + expect(toFlowchart(stray).loss).toEqual([ + { kind: "message-elements", elements: ["m-survey"] }, + ]); + expect(toStateMachine(stray, "order").loss).toEqual([ + { kind: "message-elements", elements: ["m-survey"] }, + ]); + expect(toSequence(stray, shipped).loss).toContainEqual({ + kind: "message-elements", + elements: ["m-survey"], + }); + }); +}); + +describe("state machine scope and empty lanes on the order process", () => { + it("leaves out the steps that do not affect the order and folds through them", () => { + const scoped: Model = { + ...order, + elements: order.elements.map( + (element): Model["elements"][number] => + element.kind === "activity" + ? { + ...element, + affects: element.id === "a-authorise" ? [] : ["order"], + } + : element, + ), + }; + const machine = toStateMachine(scoped, "order"); + + expect(machine.states.map((state) => state.id)).not.toContain( + "a-authorise", + ); + expect(machine.transitions).toContainEqual({ + source: "a-request", + target: "a-pack", + event: "ev-authorised", + guard: "authorised", + effect: "act-pack", + transitions: ["t3", "t4", "t5"], + through: ["a-authorise", "d-authorised"], + }); + expect(machine.loss).toContainEqual({ + kind: "subject", + subject: "order", + elements: ["a-authorise"], + }); + }); + + it("draws a lane for a declared participant with no elements", () => { + const withCourier: Model = { + ...order, + participants: [ + ...order.participants, + { id: "courier", label: "Courier", kind: "system" }, + ], + }; + + expect(toSwimlane(withCourier).lanes.at(-1)).toEqual({ + participant: "courier", + label: "Courier", + nodes: [], + }); + }); +}); diff --git a/packages/core/graph/projection/src/projections.unit.test.ts b/packages/core/graph/projection/src/projections.unit.test.ts index d12d464..2eee001 100644 --- a/packages/core/graph/projection/src/projections.unit.test.ts +++ b/packages/core/graph/projection/src/projections.unit.test.ts @@ -35,14 +35,24 @@ describe("the order process", () => { { id: "end", label: "End", shape: "terminator" }, ]); expect(flow.edges).toEqual([ - { from: "start", to: "a-submit" }, - { from: "a-submit", to: "a-request" }, - { from: "a-request", to: "a-authorise" }, - { from: "a-authorise", to: "d-authorised" }, - { from: "d-authorised", to: "a-pack", label: "authorised" }, - { from: "d-authorised", to: "a-cancel", label: "declined" }, - { from: "a-pack", to: "end" }, - { from: "a-cancel", to: "end" }, + { from: "start", to: "a-submit", transitions: ["t1"] }, + { from: "a-submit", to: "a-request", transitions: ["t2"] }, + { from: "a-request", to: "a-authorise", transitions: ["t3"] }, + { from: "a-authorise", to: "d-authorised", transitions: ["t4"] }, + { + from: "d-authorised", + to: "a-pack", + label: "authorised", + transitions: ["t5"], + }, + { + from: "d-authorised", + to: "a-cancel", + label: "declined", + transitions: ["t6"], + }, + { from: "a-pack", to: "end", transitions: ["t7"] }, + { from: "a-cancel", to: "end", transitions: ["t8"] }, ]); }); @@ -82,15 +92,27 @@ describe("the order process", () => { { id: "end", label: "End", role: "final" }, ]); expect(machine.transitions).toEqual([ - { source: "start", target: "a-submit", event: "ev-submitted" }, - { source: "a-submit", target: "a-request", effect: "act-request" }, - { source: "a-request", target: "a-authorise" }, + { + source: "start", + target: "a-submit", + event: "ev-submitted", + transitions: ["t1"], + }, + { + source: "a-submit", + target: "a-request", + effect: "act-request", + transitions: ["t2"], + }, + { source: "a-request", target: "a-authorise", transitions: ["t3"] }, { source: "a-authorise", target: "a-pack", event: "ev-authorised", guard: "authorised", effect: "act-pack", + transitions: ["t4", "t5"], + through: ["d-authorised"], }, { source: "a-authorise", @@ -98,9 +120,11 @@ describe("the order process", () => { event: "ev-declined", guard: "declined", effect: "act-cancel", + transitions: ["t4", "t6"], + through: ["d-authorised"], }, - { source: "a-pack", target: "end" }, - { source: "a-cancel", target: "end" }, + { source: "a-pack", target: "end", transitions: ["t7"] }, + { source: "a-cancel", target: "end", transitions: ["t8"] }, ]); }); @@ -162,13 +186,25 @@ describe("the order process", () => { }, }, { - kind: "message", - message: { - transition: "t5", - from: "shop", - to: "shop", - label: "payment authorised", - }, + kind: "fragment", + element: "d-authorised", + operator: "alt", + operands: [ + { + guard: "authorised", + items: [ + { + kind: "message", + message: { + transition: "t5", + from: "shop", + to: "shop", + label: "payment authorised", + }, + }, + ], + }, + ], }, ], loss: [ diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index d26b4a8..997fc3d 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -1,11 +1,17 @@ import type { - Fragment, FragmentOperator, IdSpace, + MessageMode, Model, Transition, } from "@exadev/process-graph-schema"; -import { actorMap } from "./actors.ts"; +import { actorMap, type ElementOf } from "./actors.ts"; +import { + wrapFragments, + type Entry, + type Observe, + type Step, +} from "./fragments.ts"; import { commonLoss, type Loss } from "./loss.ts"; export interface SequenceMessage { @@ -14,6 +20,10 @@ export interface SequenceMessage { /** Equal to `from` for a self-message. */ readonly to: I["actor"]; readonly label: string; + /** The `message` element drawn, when the step arrives at one; its `from`, `to`, `label` and `mode` are the message's own. In an edited diagram passed to `diffSequence`, a new message names here the element it arrives at instead. */ + readonly element?: I["element"]; + /** How the message is sent, when it is a `message` element's. A step between two activities has no mode of its own. */ + readonly mode?: MessageMode; } /** One operand of an emitted fragment: the operand's guard, when it has one, and what happens inside it. */ @@ -22,223 +32,154 @@ export interface SequenceOperand { readonly items: readonly SequenceItem[]; } -/** A message, or an authored fragment wrapping the messages of the scenario that fall inside it. */ +/** What every drawn fragment carries, authored or derived. */ +interface FragmentItem { + readonly kind: "fragment"; + readonly operator: FragmentOperator; + /** The operands the scenario takes, in authored or branch order. */ + readonly operands: readonly SequenceOperand[]; +} + +/** A message, or a fragment wrapping the messages of the scenario that fall inside it: an authored one, named by `fragment`, or one derived from the decision or fork named by `element`. */ export type SequenceItem = | { readonly kind: "message"; readonly message: SequenceMessage } - | { - readonly kind: "fragment"; + | (FragmentItem & { readonly fragment: I["fragment"]; - readonly operator: FragmentOperator; - /** The operands the scenario takes, in authored order. */ - readonly operands: readonly SequenceOperand[]; - }; + readonly element?: never; + }) + | (FragmentItem & { + readonly element: I["element"]; + readonly fragment?: never; + }); export interface SequenceDiagram { /** Actors in order of first appearance. */ readonly lifelines: readonly I["actor"][]; /** Every message in scenario order, including those inside fragments. */ readonly messages: readonly SequenceMessage[]; - /** The same messages, with authored fragments wrapped around the ones that fall inside them. */ + /** The same messages, with authored and derived fragments wrapped around the ones that fall inside them. */ readonly items: readonly SequenceItem[]; /** What the projection dropped from the model. */ readonly loss: readonly Loss[]; } -/** A scenario message with its position and the element it arrives at. */ -interface Entry { - readonly index: number; - readonly message: SequenceMessage; - readonly target: I["element"]; -} - -interface TakenOperand { - readonly guard: string | undefined; - readonly entries: readonly Entry[]; -} - -/** A place fragments are drawn into: the top level, or one operand of another fragment. */ -interface Container { - readonly children: Placed[]; -} - -/** An authored fragment the scenario passes through, with a container for the fragments nested inside each of its operands. */ -interface Placed { - readonly fragment: Fragment; - readonly operands: readonly TakenOperand[]; - readonly indices: ReadonlySet; - readonly nested: readonly Container[]; -} - /** - * Wraps the scenario's messages in the authored fragments they fall inside. - * - * A fragment is drawn when at least one of its operands has a body that lies wholly on the scenario path and receives at least one message, and when the messages it holds are consecutive in the scenario. Only the operands the scenario takes are drawn, in authored order. A fragment whose messages overlap another's without nesting, or are interleaved with messages outside it, is not drawn, because drawing it would reorder or misrepresent the scenario. Fragments are never invented: every one drawn is authored. - * - * A message belongs to an operand when the element it arrives at is in that operand's body. + * `toSequence`, telling `observe` of the work done over message positions so a caller can measure it. + * @param model - The model to project. + * @param scenario - The transitions the path takes, in order. + * @param observe - Told of work done over message positions. */ -const wrapFragments = ( - fragments: readonly Fragment[], - entries: readonly Entry[], - onPath: ReadonlySet, -): { - readonly items: SequenceItem[]; - readonly drawn: Set; -} => { - const candidates: Placed[] = []; - for (const fragment of fragments) { - const claimed = new Set(); - const operands: TakenOperand[] = []; - for (const operand of fragment.operands) { - if ( - operand.body.length === 0 || - !operand.body.every((id) => onPath.has(id)) - ) { - continue; - } - const inside = entries.filter( - (entry) => - operand.body.includes(entry.target) && !claimed.has(entry.index), - ); - if (inside.length === 0) { - continue; - } - for (const entry of inside) { - claimed.add(entry.index); - } - operands.push({ guard: operand.guard, entries: inside }); - } - const first = Math.min(...claimed); - const last = Math.max(...claimed); - if (operands.length > 0 && last - first + 1 === claimed.size) { - candidates.push({ - fragment, - operands, - indices: claimed, - nested: operands.map(() => ({ children: [] })), - }); - } - } - - // Largest first, so a fragment nests inside any larger one that holds all its messages. - const bySize = candidates.toSorted((a, b) => b.indices.size - a.indices.size); - const top: Container = { children: [] }; - const place = (node: Placed, container: Container): void => { - for (const sibling of container.children) { - const shared = [...node.indices].filter((index) => - sibling.indices.has(index), - ); - if (shared.length === 0) { - continue; - } - const slot = sibling.operands.findIndex((operand) => - [...node.indices].every((index) => - operand.entries.some((entry) => entry.index === index), - ), - ); - const inner = slot === -1 ? undefined : sibling.nested[slot]; - if (inner !== undefined) { - place(node, inner); - } - - return; - } - container.children.push(node); - }; - for (const node of bySize) { - place(node, top); - } - - const drawn = new Set(); - const itemsOf = ( - inside: readonly Entry[], - nodes: readonly Placed[], - ): SequenceItem[] => { - const items: SequenceItem[] = []; - const emitted = new Set>(); - for (const entry of inside) { - const owner = nodes.find((node) => node.indices.has(entry.index)); - if (owner === undefined) { - items.push({ kind: "message", message: entry.message }); - } else if (!emitted.has(owner)) { - emitted.add(owner); - drawn.add(owner.fragment.id); - items.push({ - kind: "fragment", - fragment: owner.fragment.id, - operator: owner.fragment.operator, - operands: owner.operands.map((operand, slot) => ({ - ...(operand.guard === undefined ? {} : { guard: operand.guard }), - items: itemsOf(operand.entries, owner.nested[slot]?.children ?? []), - })), - }); - } - } - - return items; - }; - - return { items: itemsOf(entries, top.children), drawn }; -}; - -/** - * One path through the process as messages. A step between two elements with actors becomes a message, labelled by the transition's event when it has one and by the target's label otherwise. Steps touching an element with no actor are skipped and reported as `skipped-steps`. Branches off the path are not drawn, which is why this direction is lossy and one-to-many. - -`messages` is the flat list. `items` is the same messages with the authored fragments the scenario passes through wrapped around the ones that fall inside them; a fragment that cannot be drawn faithfully is left out and reported in `loss`. - */ -export const toSequence = ( +export const sequenceObserved = ( model: Model, scenario: readonly I["transition"][], + observe: Observe | undefined, ): SequenceDiagram => { const actors = actorMap(model); - const labels = new Map(); - for (const element of model.elements) { - if (element.kind === "activity" || element.kind === "decision") { - labels.set(element.id, element.label); - } - } + const elements = new Map>( + model.elements.map((element) => [element.id, element]), + ); const transitions = new Map>( model.transitions.map((transition) => [transition.id, transition]), ); const eventNames = new Map( model.events.map((event) => [event.id, event.name]), ); + const labelOf = (id: I["element"]): string | undefined => { + const element = elements.get(id); + + return element?.kind === "activity" || element?.kind === "decision" + ? element.label + : undefined; + }; const skipped: I["transition"][] = []; const lifelines: I["actor"][] = []; + const onLifeline = new Set(); const messages: SequenceMessage[] = []; const entries: Entry[] = []; + const steps: Step[] = []; + const shownMessages = new Set(); + // The actor holding control at each element the scenario has reached, so a step leaving an element with no actor has a sender. + const holder = new Map(); + let arrivedAt: I["element"] | undefined; + const draw = (message: SequenceMessage, target: I["element"]): void => { + for (const participant of [message.from, message.to]) { + if (!onLifeline.has(participant)) { + onLifeline.add(participant); + lifelines.push(participant); + } + } + entries.push({ index: messages.length, message, target }); + messages.push(message); + }; for (const id of scenario) { const transition = transitions.get(id); if (transition === undefined) { throw new Error(`Scenario step "${id}" is not a declared transition`); } - const from = actors.get(transition.source); + steps.push({ transition, before: entries.length }); + const source = elements.get(transition.source); + const target = elements.get(transition.target); + const previous = arrivedAt; + arrivedAt = transition.target; + if (target?.kind === "message") { + shownMessages.add(target.id); + holder.set(target.id, target.to); + draw( + { + transition: id, + from: target.from, + to: target.to, + label: target.label, + element: target.id, + mode: target.mode, + }, + target.id, + ); + continue; + } + if (source?.kind === "message") { + if (previous !== source.id) { + shownMessages.add(source.id); + draw( + { + transition: id, + from: source.from, + to: source.to, + label: source.label, + element: source.id, + mode: source.mode, + }, + source.id, + ); + } + const to = actors.get(transition.target); + holder.set(transition.target, to ?? source.to); + continue; + } + const from = actors.get(transition.source) ?? holder.get(transition.source); const to = actors.get(transition.target); + const held = to ?? from; + if (held !== undefined) { + holder.set(transition.target, held); + } if (from === undefined || to === undefined) { skipped.push(id); continue; } - for (const participant of [from, to]) { - if (!lifelines.includes(participant)) { - lifelines.push(participant); - } - } const eventName = transition.trigger === undefined ? undefined : eventNames.get(transition.trigger); - const message: SequenceMessage = { - transition: id, - from, - to, - label: eventName ?? labels.get(transition.target) ?? id, - }; - entries.push({ - index: messages.length, - message, - target: transition.target, - }); - messages.push(message); + draw( + { + transition: id, + from, + to, + label: eventName ?? labelOf(transition.target) ?? id, + }, + transition.target, + ); } const onPath = new Set(); @@ -255,9 +196,20 @@ export const toSequence = ( (transition) => !taken.has(transition.id) && onPath.has(transition.source), ); - const { items, drawn } = wrapFragments(model.fragments, entries, onPath); - - const loss: Loss[] = [...commonLoss(model, "sequence", drawn)]; + const { items, drawn } = wrapFragments({ + model, + steps, + entries, + onPath, + ...(observe === undefined ? {} : { observe }), + }); + + const loss: Loss[] = [ + ...commonLoss(model, "sequence", { + fragments: drawn, + messages: shownMessages, + }), + ]; if (offPath.length > 0) { loss.push({ kind: "scenario", @@ -277,3 +229,19 @@ export const toSequence = ( return { lifelines, messages, items, loss }; }; + +/** + * One path through the process as messages. + * + * A step between two elements with actors becomes a message, labelled by the transition's event when it has one and by the target's label otherwise. A step arriving at a `message` element draws that message, with its own `from`, `to`, `label` and `mode`, and the step leaving it continues the same message rather than drawing another. An element with no actor that the scenario passes through, such as a fork or join, passes on whoever reached it: a step leaving it is sent by the actor of the step that arrived. A step that still has no sender, or that arrives at an element with no actor, draws nothing and is reported as `skipped-steps`. Branches off the path are not drawn, which is why this direction is lossy and one-to-many. + * + * `messages` is the flat list. `items` is the same messages with fragments wrapped around the ones that fall inside them: the authored `fragments` the scenario passes through, and those its structure implies (an `alt` or `opt` at a decision, a `loop` back through a decision, a `par` at a fork), each derived fragment naming the decision or fork it comes from. A fragment that cannot be drawn faithfully is left out, and an authored one left out is reported in `loss`. + * @param model - The model to project. + * @param scenario - The transitions the path takes, in order. + * @returns Lifelines in order of first appearance, the messages, the items and what was dropped. + * @throws Error when a scenario step is not a declared transition. + */ +export const toSequence = ( + model: Model, + scenario: readonly I["transition"][], +): SequenceDiagram => sequenceObserved(model, scenario, undefined); diff --git a/packages/core/graph/projection/src/sequence.unit.test.ts b/packages/core/graph/projection/src/sequence.unit.test.ts index abfe8bc..3fdbf22 100644 --- a/packages/core/graph/projection/src/sequence.unit.test.ts +++ b/packages/core/graph/projection/src/sequence.unit.test.ts @@ -78,14 +78,47 @@ const outcome = { ], } as const; -describe("toSequence without fragments", () => { - it("lists every message as a message item in scenario order", () => { +/** The authored fragment ids drawn anywhere in `items`, outermost first. */ +const authoredIn = (items: readonly SequenceItem[]): string[] => + items.flatMap((item) => + item.kind === "fragment" + ? [ + ...(item.fragment === undefined ? [] : [item.fragment]), + ...item.operands.flatMap((operand) => authoredIn(operand.items)), + ] + : [], + ); + +/** The alt the payment decision implies when the scenario ships. */ +const shipping: SequenceItem = { + kind: "fragment", + element: "d-ok", + operator: "alt", + operands: [ + { guard: "authorised", items: [message("t6", "shop", "shop", "Ship")] }, + ], +}; + +describe("toSequence without authored fragments", () => { + it("derives a loop and an alt from the payment decision, keeping messages flat in `messages`", () => { const diagram = toSequence(withFragments([]), ["t2", "t3", "t6"]); expect(diagram.items).toEqual([ - message("t2", "customer", "shop", "Take payment"), - message("t3", "shop", "shop", "Paid?"), - message("t6", "shop", "shop", "Ship"), + { + kind: "fragment", + element: "d-ok", + operator: "loop", + operands: [ + { + guard: "declined", + items: [ + message("t2", "customer", "shop", "Take payment"), + message("t3", "shop", "shop", "Paid?"), + ], + }, + ], + }, + shipping, ]); expect(diagram.messages.map((entry) => entry.transition)).toEqual([ "t2", @@ -96,7 +129,7 @@ describe("toSequence without fragments", () => { }); describe("toSequence with authored fragments", () => { - it("wraps the messages a loop covers and leaves the rest outside", () => { + it("wraps the messages a loop covers, in place of the loop the decision implies", () => { const diagram = toSequence(withFragments([paymentLoop]), retried); expect(diagram.items).toEqual([ @@ -117,7 +150,7 @@ describe("toSequence with authored fragments", () => { }, ], }, - message("t6", "shop", "shop", "Ship"), + shipping, ]); expect(diagram.loss).not.toContainEqual( expect.objectContaining({ kind: "fragments" }), @@ -202,7 +235,7 @@ describe("toSequence with authored fragments", () => { }, ], }, - message("t6", "shop", "shop", "Ship"), + shipping, ], }, ], @@ -218,11 +251,7 @@ describe("toSequence with authored fragments", () => { } as const; const diagram = toSequence(withFragments([split]), ["t2", "t3", "t6"]); - expect(diagram.items.map((item) => item.kind)).toEqual([ - "message", - "message", - "message", - ]); + expect(authoredIn(diagram.items)).toEqual([]); expect(diagram.loss).toContainEqual({ kind: "fragments", fragments: ["f-split"], @@ -237,11 +266,7 @@ describe("toSequence with authored fragments", () => { } as const; const diagram = toSequence(withFragments([offPath]), ["t2", "t3", "t6"]); - expect(diagram.items.map((item) => item.kind)).toEqual([ - "message", - "message", - "message", - ]); + expect(authoredIn(diagram.items)).toEqual([]); expect(diagram.loss).toContainEqual({ kind: "fragments", fragments: ["f-off"], @@ -265,11 +290,7 @@ describe("toSequence with authored fragments", () => { "t4", ]); - const drawn = diagram.items.flatMap((item) => - item.kind === "fragment" ? [item.fragment] : [], - ); - - expect(drawn).toEqual(["f-left"]); + expect(authoredIn(diagram.items)).toEqual(["f-left"]); expect(diagram.loss).toContainEqual({ kind: "fragments", fragments: ["f-right"], diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index b329458..eebc2bc 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -1,5 +1,14 @@ -import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; +import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; +import { + chainIndex, + once, + outgoingMap, + type Chain, + type ChainIndex, + type TransitionOf, +} from "./chains.ts"; +import type { EdgeOrigin } from "./flowchart.ts"; import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface StateMachineState { @@ -14,6 +23,10 @@ export interface StateMachineTransition { readonly event?: I["event"]; readonly guard?: string; readonly effect?: I["action"]; + /** The model transitions this transition stands for: one, or every transition on some route through the elements folded into it, each once, in the order they were met. An edit to it maps back to the model through these. */ + readonly transitions: readonly I["transition"][]; + /** The elements folded into it, each once, in the order they were met: decisions, activities that do not affect the subject, and `message` elements. */ + readonly through?: readonly I["element"][]; } export interface StateMachine { @@ -48,19 +61,22 @@ const stateOf = ( return undefined; }; +/** A state machine with what its inverse needs beside it: each transition's origin, in transition order, and the chain index. */ +export interface StateMachineParts { + readonly machine: StateMachine; + readonly origins: readonly EdgeOrigin[]; + readonly index: ChainIndex; +} + /** - * Activity-as-state projection. Each activity that affects the subject becomes a state the subject entity rests in, and a decision collapses into guarded transitions leaving the state before it. The target activity's action becomes the effect of the transition that enters it. - * - * Which activities affect the subject is declared by their `affects`. An activity that does not list the subject is not a state of it: transitions through it are folded into the transition that leaves the previous state, exactly as a decision is. Authored `state` elements and pseudo-states are always kept. When no activity in the model declares `affects`, the model carries no such information and every activity is kept, which is the unscoped behaviour. - * - * Decisions are folded into guards by design and are not a loss. Fork, join and history pseudo-states have no state of their own here and are reported as unrepresented, together with the transitions that touch them, which are left out rather than pointing at a state that is not drawn. - * - * Other conventions exist, such as steps as transitions with states between them. Naming states such as "Paid" needs authored states, which the model can hold as `state` elements. + * The state machine and the parts its inverse needs; see `toStateMachine`. + * @param model - The model to project. + * @param subject - The entity whose lifecycle the machine shows. */ -export const toStateMachine = ( +export const stateMachineParts = ( model: Model, subject: I["entity"], -): StateMachine => { +): StateMachineParts => { // The type already limits `subject` to declared entities. This guards models loaded from JSON. const owner = model.participants.find( (participant) => participant.id === subject, @@ -71,81 +87,70 @@ export const toStateMachine = ( ); } - interface Arrival { - readonly target: I["element"]; - readonly event: I["event"] | undefined; - readonly guard: string | undefined; - readonly effect: I["action"] | undefined; - } - const elements = new Map>( model.elements.map((element) => [element.id, element]), ); - const outgoing = new Map[]>(); - for (const transition of model.transitions) { - const list = outgoing.get(transition.source); - if (list === undefined) { - outgoing.set(transition.source, [transition]); - } else { - list.push(transition); - } - } + const outgoing = outgoingMap(model); const scoped = model.elements.some( (element) => element.kind === "activity" && element.affects !== undefined, ); - /** Whether an element is folded through rather than shown as a state: a decision, or an activity that does not affect the subject in a model that declares what activities affect. */ + /** Whether an element is folded through rather than shown as a state: a decision, a `message` element, or an activity that does not affect the subject in a model that declares what activities affect. */ const isTransparent = (element: ElementOf): boolean => element.kind === "decision" || + element.kind === "message" || (element.kind === "activity" && scoped && element.affects?.includes(subject) !== true); - const joinGuards = ( - first: string | undefined, - second: string | undefined, - ): string | undefined => { - if (first === undefined) { - return second; + /** The transition a chain draws. On a single route, the event is the last trigger along it, counting a folded message's own event right after the transition that arrives at it; guards are joined in order; the effect is the entry action of the target activity, or else the last effect along the route. Where several routes are merged their triggers, guards and effects belong to different routes, so only the target's entry action is kept. A transition through folded elements builds its lists and guard only when they are read. */ + const transitionOf = (chain: Chain): StateMachineTransition => { + const target = elements.get(chain.target); + const event = chain.trigger; + const effect = + (target?.kind === "activity" ? target.action : undefined) ?? chain.effect; + const base = { + source: chain.first.source, + target: chain.target, + ...(event === undefined ? {} : { event }), + ...(effect === undefined ? {} : { effect }), + }; + if (chain.first.target === chain.target) { + const { guard } = chain.first; + + return { + ...base, + ...(guard === undefined ? {} : { guard }), + transitions: [chain.first.id], + }; } - - return second === undefined ? first : `${first} and ${second}`; - }; - - const resolve = ( - transition: Transition, - seen: ReadonlySet, - ): readonly Arrival[] => { - const target = elements.get(transition.target); - if (target !== undefined && isTransparent(target) && !seen.has(target.id)) { - const visited = new Set(seen).add(target.id); - const arrivals: Arrival[] = []; - for (const onward of outgoing.get(target.id) ?? []) { - for (const arrival of resolve(onward, visited)) { - arrivals.push({ - target: arrival.target, - event: arrival.event ?? transition.trigger, - guard: joinGuards(transition.guard, arrival.guard), - effect: arrival.effect ?? transition.effect, - }); + const ids = once(() => + chain.transitions().map((transition) => transition.id), + ); + const guard = once(() => chain.guards().join(" and ")); + + return chain.guarded + ? { + ...base, + get guard(): string { + return guard(); + }, + get transitions(): readonly I["transition"][] { + return ids(); + }, + get through(): readonly I["element"][] { + return chain.through(); + }, } - } - - return arrivals; - } - const entryAction = - target?.kind === "activity" && !isTransparent(target) - ? target.action - : undefined; - - return [ - { - target: transition.target, - event: transition.trigger, - guard: transition.guard, - effect: entryAction ?? transition.effect, - }, - ]; + : { + ...base, + get transitions(): readonly I["transition"][] { + return ids(); + }, + get through(): readonly I["element"][] { + return chain.through(); + }, + }; }; const states: StateMachineState[] = []; @@ -158,7 +163,18 @@ export const toStateMachine = ( stateIds.add(element.id); } } + const index = chainIndex(model, { + folded: (id) => folded.has(id), + accepted: (id) => stateIds.has(id), + eventOf: (id) => { + const element = elements.get(id); + + return element?.kind === "message" ? element.event : undefined; + }, + }); const transitions: StateMachineTransition[] = []; + const origins: EdgeOrigin[] = []; + const firsts: TransitionOf[] = []; for (const element of model.elements) { const state = isTransparent(element) ? undefined : stateOf(element); if (state === undefined) { @@ -166,31 +182,35 @@ export const toStateMachine = ( } states.push(state); for (const transition of outgoing.get(element.id) ?? []) { - for (const arrival of resolve(transition, new Set())) { - if (!stateIds.has(arrival.target)) { - continue; - } - transitions.push({ - source: element.id, - target: arrival.target, - ...(arrival.event === undefined ? {} : { event: arrival.event }), - ...(arrival.guard === undefined ? {} : { guard: arrival.guard }), - ...(arrival.effect === undefined ? {} : { effect: arrival.effect }), - }); + firsts.push(transition); + for (const chain of index.from(transition)) { + transitions.push(transitionOf(chain)); + origins.push({ first: transition, target: chain.target }); } } } + const carried = index.carried(firsts); const loss: Loss[] = [ - ...commonLoss(model, "stateMachine"), + ...commonLoss(model, "stateMachine", { + fragments: new Set(), + messages: new Set([...carried, ...index.unfolded]), + }), ...unrepresentedLoss( model, "stateMachine", - new Set([...stateIds, ...folded]), + new Set([ + ...stateIds, + ...[...folded].filter((id) => !index.unfolded.has(id)), + ]), + index.unfolded, ), ]; const skipped = model.elements.filter( - (element) => element.kind === "activity" && isTransparent(element), + (element) => + element.kind === "activity" && + isTransparent(element) && + !index.unfolded.has(element.id), ); if (skipped.length > 0) { loss.push({ @@ -200,5 +220,27 @@ export const toStateMachine = ( }); } - return { subject, states, transitions, loss }; + return { machine: { subject, states, transitions, loss }, origins, index }; }; + +/** + * Activity-as-state projection. Each activity that affects the subject becomes a state the subject entity rests in, and a decision collapses into guarded transitions leaving the state before it. The target activity's action becomes the effect of the transition that enters it. + * + * Which activities affect the subject is declared by their `affects`. An activity that does not list the subject is not a state of it: transitions through it are folded into the transition that leaves the previous state, exactly as a decision is. Authored `state` elements and pseudo-states are always kept. When no activity in the model declares `affects`, the model carries no such information and every activity is kept, which is the unscoped behaviour. + * + * A `message` element is folded the same way, and its `event`, when it has one, triggers the transition that carries it. A message element no drawn transition carries is reported as a `message-elements` loss. + * + * Each drawn transition lists the model `transitions` it stands for and the elements folded into it (`through`), so an edit to it maps back to the model. Several routes through folded elements from the same transition to the same state are one drawn transition whose `transitions` and `through` are the union over the routes, each once in the order met; it carries no event or guard, which differ by route, and its effect is only the target's entry action. + * + * Decisions are folded into guards by design and are not a loss. Fork, join and history pseudo-states have no state of their own here and are reported as unrepresented, together with the transitions that touch them, which are left out rather than pointing at a state that is not drawn. + * + * Other conventions exist, such as steps as transitions with states between them. Naming states such as "Paid" needs authored states, which the model can hold as `state` elements. + * @param model - The model to project. + * @param subject - The entity whose lifecycle the machine shows. + * @returns The states in model order, the transitions leaving each in turn, and what was dropped. + * @throws Error when `subject` is not a declared entity participant, which only a runtime model can cause. + */ +export const toStateMachine = ( + model: Model, + subject: I["entity"], +): StateMachine => stateMachineParts(model, subject).machine; diff --git a/packages/core/graph/projection/src/state-machine.unit.test.ts b/packages/core/graph/projection/src/state-machine.unit.test.ts index 081a1c3..f70947c 100644 --- a/packages/core/graph/projection/src/state-machine.unit.test.ts +++ b/packages/core/graph/projection/src/state-machine.unit.test.ts @@ -59,9 +59,15 @@ describe("toStateMachine scoping by affects", () => { "end", ]); expect(machine.transitions).toEqual([ - { source: "start", target: "a-accept" }, - { source: "a-accept", target: "a-ship", event: "ev-paid" }, - { source: "a-ship", target: "end" }, + { source: "start", target: "a-accept", transitions: ["t1"] }, + { + source: "a-accept", + target: "a-ship", + event: "ev-paid", + transitions: ["t2", "t3"], + through: ["a-invoice"], + }, + { source: "a-ship", target: "end", transitions: ["t4"] }, ]); }); @@ -74,8 +80,20 @@ describe("toStateMachine scoping by affects", () => { "end", ]); expect(machine.transitions).toEqual([ - { source: "start", target: "a-invoice", effect: "act-issue" }, - { source: "a-invoice", target: "end", event: "ev-paid" }, + { + source: "start", + target: "a-invoice", + effect: "act-issue", + transitions: ["t1", "t2"], + through: ["a-accept"], + }, + { + source: "a-invoice", + target: "end", + event: "ev-paid", + transitions: ["t3", "t4"], + through: ["a-ship"], + }, ]); }); @@ -97,7 +115,9 @@ describe("toStateMachine scoping by affects", () => { const machine = toStateMachine(unscoped, "order"); expect(machine.states.map((state) => state.id)).toEqual(["a", "b"]); - expect(machine.transitions).toEqual([{ source: "a", target: "b" }]); + expect(machine.transitions).toEqual([ + { source: "a", target: "b", transitions: ["t1"] }, + ]); }); it("drops an activity with no affects once any activity declares it", () => { diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index c570f19..9c20edc 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -1,6 +1,6 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; -import { flowchartLoss, toFlowchart, type Flowchart } from "./flowchart.ts"; +import { flowchartLoss, flowchartParts, type Flowchart } from "./flowchart.ts"; import { handoffs, type Handoff } from "./handoffs.ts"; /** One lane. `participant` is a declared role or system; in a runtime model it may instead be an actor that was named but never declared, which `validate` reports. */ @@ -21,7 +21,8 @@ export interface Swimlane extends Flowchart { * A flowchart plus partitions: a lane for every declared role or system, in declaration order and empty when nothing is assigned to it, then a lane for each actor that was named but never declared. Hand-offs are the edges that change lane. */ export const toSwimlane = (model: Model): Swimlane => { - const flow = toFlowchart(model); + const parts = flowchartParts(model); + const flow = parts.flowchart; const actors = actorMap(model); const members = new Map(); @@ -64,6 +65,10 @@ export const toSwimlane = (model: Model): Swimlane => { lanes, unassigned, handoffs: handoffs(model), - loss: flowchartLoss(model, "swimlane", flow.nodes), + loss: flowchartLoss(model, "swimlane", { + nodes: flow.nodes, + carried: parts.carried, + unfolded: parts.index.unfolded, + }), }; }; diff --git a/packages/core/graph/projection/src/test-support/order-variants.ts b/packages/core/graph/projection/src/test-support/order-variants.ts new file mode 100644 index 0000000..4822bec --- /dev/null +++ b/packages/core/graph/projection/src/test-support/order-variants.ts @@ -0,0 +1,310 @@ +import { defineModel } from "@exadev/process-graph-schema"; + +/** The order process from `order.ts`, with the payment request and the dispatch notice sent as `message` elements between the steps. */ +export const orderWithMessages = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + { id: "ev-charge", name: "card charged" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "message", + id: "m-charge", + label: "Charge card", + from: "shop", + to: "provider", + mode: "sync", + event: "ev-charge", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "message", + id: "m-dispatch", + label: "Dispatch notice", + from: "shop", + to: "customer", + mode: "async", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3a", source: "a-request", target: "m-charge" }, + { id: "t3b", source: "m-charge", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t7a", source: "a-pack", target: "m-dispatch" }, + { id: "t7b", source: "m-dispatch", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [], +}); + +/** The order process where a declined payment goes back to the customer to re-enter their card and the payment is requested again, until it is authorised or the customer gives up. */ +export const orderWithRetry = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { + kind: "activity", + id: "a-retry", + label: "Re-enter card", + actor: "customer", + }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "a-pack", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-retry", + guard: "declined", + trigger: "ev-declined", + }, + { id: "t9", source: "a-retry", target: "a-request" }, + { id: "t10", source: "d-authorised", target: "a-cancel", guard: "gave up" }, + { id: "t7", source: "a-pack", target: "end" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [], +}); + +/** The order process where, once payment is authorised, the shop packs the order while the provider sends a receipt, and the shop then gift-wraps it only when asked to. */ +export const orderWithFulfilment = defineModel({ + participants: [ + { id: "customer", label: "Customer", kind: "role" }, + { id: "shop", label: "Shop", kind: "system" }, + { id: "provider", label: "Payment provider", kind: "system" }, + { id: "order", label: "Order", kind: "entity" }, + ], + events: [ + { id: "ev-submitted", name: "order submitted" }, + { id: "ev-authorised", name: "payment authorised" }, + { id: "ev-declined", name: "payment declined" }, + ], + actions: [ + { id: "act-request", name: "request payment" }, + { id: "act-pack", name: "pack and ship" }, + { id: "act-cancel", name: "cancel order and notify" }, + ], + artefacts: [], + elements: [ + { kind: "pseudo", id: "start", role: "initial" }, + { + kind: "activity", + id: "a-submit", + label: "Submit order", + actor: "customer", + }, + { + kind: "activity", + id: "a-request", + label: "Request payment", + actor: "shop", + action: "act-request", + }, + { + kind: "activity", + id: "a-authorise", + label: "Authorise or decline", + actor: "provider", + }, + { + kind: "decision", + id: "d-authorised", + label: "Payment authorised?", + actor: "shop", + }, + { kind: "pseudo", id: "fork", role: "fork" }, + { + kind: "activity", + id: "a-pack", + label: "Pack and ship", + actor: "shop", + action: "act-pack", + }, + { + kind: "activity", + id: "a-receipt", + label: "Send receipt", + actor: "provider", + }, + { kind: "pseudo", id: "join", role: "join" }, + { kind: "decision", id: "d-gift", label: "Gift wrap?", actor: "shop" }, + { kind: "activity", id: "a-wrap", label: "Wrap gift", actor: "shop" }, + { + kind: "activity", + id: "a-cancel", + label: "Cancel order and notify", + actor: "shop", + action: "act-cancel", + }, + { kind: "pseudo", id: "end", role: "final" }, + ], + transitions: [ + { id: "t1", source: "start", target: "a-submit", trigger: "ev-submitted" }, + { id: "t2", source: "a-submit", target: "a-request" }, + { id: "t3", source: "a-request", target: "a-authorise" }, + { id: "t4", source: "a-authorise", target: "d-authorised" }, + { + id: "t5", + source: "d-authorised", + target: "fork", + guard: "authorised", + trigger: "ev-authorised", + }, + { + id: "t6", + source: "d-authorised", + target: "a-cancel", + guard: "declined", + trigger: "ev-declined", + }, + { id: "tf1", source: "fork", target: "a-pack" }, + { id: "tf2", source: "fork", target: "a-receipt" }, + { id: "tj1", source: "a-pack", target: "join" }, + { id: "tj2", source: "a-receipt", target: "join" }, + { id: "tg", source: "join", target: "d-gift" }, + { id: "tw", source: "d-gift", target: "a-wrap", guard: "gift" }, + { id: "tw2", source: "a-wrap", target: "end" }, + { id: "tn", source: "d-gift", target: "end", guard: "no gift" }, + { id: "t8", source: "a-cancel", target: "end" }, + ], + fragments: [], + projections: [], +}); diff --git a/packages/core/graph/projection/src/test-support/shapes.ts b/packages/core/graph/projection/src/test-support/shapes.ts new file mode 100644 index 0000000..5fd4be9 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -0,0 +1,389 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; + +/** + * Shapes a review found quadratic work in, each built at a given size, for the scaling table in `bounded-work.unit.test.ts`. + */ + +/** Numbered identifiers with a prefix. */ +export const ids = (count: number, prefix: string): string[] => + Array.from({ length: count }, (_, index) => `${prefix}${String(index)}`); + +/** A model with nothing in it, to build shapes from. */ +export const empty: Model = { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [], +}; + +/** The participants every shape uses: a customer, a shop, and the order a state machine follows. */ +export const actors = [ + { id: "customer", label: "Customer", kind: "role" as const }, + { id: "shop", label: "Shop", kind: "system" as const }, + { id: "order", label: "Order", kind: "entity" as const }, +]; + +const activity = (id: string, actor = "shop") => ({ + kind: "activity" as const, + id, + label: id, + actor, +}); +const messageElement = (id: string) => ({ + kind: "message" as const, + id, + label: id, + from: "customer", + to: "shop", + mode: "async" as const, +}); + +/** A model and a path through it. */ +export interface Shape { + readonly model: Model; + readonly path: readonly string[]; +} + +/** Messages in a line, each with its own exit, entered from one activity. */ +export const combShape = (count: number): Shape => { + const teeth = ids(count, "m-"); + + return { + model: { + ...empty, + participants: actors, + elements: [ + activity("s", "customer"), + ...teeth.map(messageElement), + ...teeth.map((id) => activity(`e-${id}`)), + ], + transitions: [ + { id: "t-in", source: "s", target: "m-0" }, + ...teeth.flatMap((id, index) => [ + { id: `t-${id}-out`, source: id, target: `e-${id}` }, + { + id: `t-${id}-on`, + source: id, + target: teeth[index + 1] ?? `e-${id}`, + }, + ]), + ], + }, + path: ["t-in", ...teeth.map((id) => `t-${id}-on`)], + }; +}; + +/** Two combs from one message, joined at a last message, exiting to shared activities. */ +export const diamondShape = (count: number): Shape => { + const shared = ids(count, "e-"); + const arm = (side: string): string[] => + shared.map((_, index) => `${side}-${String(index)}`); + const left = arm("l"); + const right = arm("r"); + const teeth = (side: readonly string[]) => + side.flatMap((id, index) => [ + { id: `t-${id}-out`, source: id, target: shared[index] ?? id }, + { id: `t-${id}-on`, source: id, target: side[index + 1] ?? "join" }, + ]); + + return { + model: { + ...empty, + participants: actors, + elements: [ + activity("s", "customer"), + ...["fork", ...left, ...right, "join"].map(messageElement), + ...shared.map((id) => activity(id)), + activity("last"), + ], + transitions: [ + { id: "t-in", source: "s", target: "fork" }, + { id: "t-left", source: "fork", target: left[0] ?? "join" }, + { id: "t-right", source: "fork", target: right[0] ?? "join" }, + ...teeth(left), + ...teeth(right), + { id: "t-last", source: "join", target: "last" }, + ], + }, + path: ["t-in", "t-left", ...left.map((id) => `t-${id}-on`), "t-last"], + }; +}; + +/** Activities each entering their own message of one run, every message also leaving for the same end. */ +export const sourcesIntoRunShape = (count: number): Shape => { + const run = ids(count, "m-"); + + return { + model: { + ...empty, + participants: actors, + elements: [ + ...run.map((id) => activity(`s-${id}`, "customer")), + ...run.map(messageElement), + activity("end"), + ], + transitions: run.flatMap((id, index) => [ + { id: `t-s-${id}`, source: `s-${id}`, target: id }, + { id: `t-${id}-on`, source: id, target: run[index + 1] ?? "end" }, + { id: `t-${id}-end`, source: id, target: "end" }, + ]), + }, + path: ["t-s-m-0", ...run.map((id) => `t-${id}-on`)], + }; +}; + +/** One activity stepping round to itself, with that many authored fragments over it. */ +export const authoredRepeatsShape = (count: number): Shape => ({ + model: { + ...empty, + participants: actors, + elements: [activity("x")], + transitions: [{ id: "t-loop", source: "x", target: "x" }], + fragments: ids(count, "f-").map((id) => ({ + id, + operator: "opt" as const, + operands: [{ body: ["x"] }], + })), + }, + path: Array.from({ length: count }, () => "t-loop"), +}); + +/** A fork into that many branches, each one step, joined again, with the path taking every branch. */ +export const wideForkShape = (count: number): Shape => { + const branches = ids(count, "b-"); + + return { + model: { + ...empty, + participants: actors, + elements: [ + activity("start"), + { kind: "pseudo", id: "fork", role: "fork" }, + ...branches.map((id) => activity(id)), + { kind: "pseudo", id: "join", role: "join" }, + activity("end"), + ], + transitions: [ + { id: "t-start", source: "start", target: "fork" }, + ...branches.flatMap((id) => [ + { id: `t-to-${id}`, source: "fork", target: id }, + { id: `t-from-${id}`, source: id, target: "join" }, + ]), + { id: "t-end", source: "join", target: "end" }, + ], + }, + path: [ + "t-start", + ...branches.flatMap((id) => [`t-to-${id}`, `t-from-${id}`]), + "t-end", + ], + }; +}; + +/** Diamonds of messages in a line, each splitting into two messages that meet again, with each meeting point also leaving for its own activity. */ +export const serialDiamondsShape = (count: number): Shape => { + const diamonds = ids(count, "d"); + const top = (index: number): string => `top-${String(index)}`; + const bottom = (index: number): string => `bottom-${String(index)}`; + + return { + model: { + ...empty, + participants: actors, + elements: [ + activity("s", "customer"), + ...diamonds.flatMap((id, index) => + [top(index), `${id}-a`, `${id}-b`, bottom(index)].map(messageElement), + ), + ...diamonds.map((id) => activity(`e-${id}`)), + activity("end"), + ], + transitions: [ + { id: "t-in", source: "s", target: top(0) }, + ...diamonds.flatMap((id, index) => [ + { id: `t-${id}-a`, source: top(index), target: `${id}-a` }, + { id: `t-${id}-b`, source: top(index), target: `${id}-b` }, + { id: `t-${id}-a-meet`, source: `${id}-a`, target: bottom(index) }, + { id: `t-${id}-b-meet`, source: `${id}-b`, target: bottom(index) }, + { id: `t-${id}-out`, source: bottom(index), target: `e-${id}` }, + { + id: `t-${id}-on`, + source: bottom(index), + target: index + 1 < count ? top(index + 1) : "end", + }, + ]), + ], + }, + path: [ + "t-in", + ...diamonds.flatMap((id) => [ + `t-${id}-a`, + `t-${id}-a-meet`, + `t-${id}-on`, + ]), + ], + }; +}; + +/** Sources each entering their own message, every message leaving for one shared end. */ +export const sharedEndShape = (count: number): Shape => { + const sources = ids(count, "s-"); + + return { + model: { + ...empty, + participants: actors, + elements: [ + ...sources.map((id) => activity(id, "customer")), + ...sources.map((id) => messageElement(`m-${id}`)), + activity("end"), + ], + transitions: sources.flatMap((id) => [ + { id: `t-${id}`, source: id, target: `m-${id}` }, + { id: `t-m-${id}`, source: `m-${id}`, target: "end" }, + ]), + }, + path: [], + }; +}; + +/** Activities in a line, one authored fragment over all of them, and one authored fragment over each of them nested inside it. */ +export const authoredNestingShape = (count: number): Shape => { + const steps = ids(count, "x-"); + + return { + model: { + ...empty, + participants: actors, + elements: steps.map((id) => activity(id)), + transitions: steps.slice(1).map((id, index) => ({ + id: `t-${id}`, + source: steps[index] ?? id, + target: id, + })), + fragments: [ + { id: "outer", operator: "opt", operands: [{ body: steps }] }, + ...steps.map((id) => ({ + id: `f-${id}`, + operator: "opt" as const, + operands: [{ body: [id] }], + })), + ], + }, + path: steps.slice(1).map((id) => `t-${id}`), + }; +}; + +/** One message splitting into two messages that both lead to every one of many activities, so every edge into those activities is a merged chain over the same branches. */ +export const twoBranchesShape = (count: number): Shape => { + const ends = ids(count, "e-"); + + return { + model: { + ...empty, + participants: actors, + elements: [ + activity("s", "customer"), + ...["split", "left", "right"].map(messageElement), + ...ends.map((id) => activity(id)), + ], + transitions: [ + { id: "t-in", source: "s", target: "split" }, + { id: "t-left", source: "split", target: "left" }, + { id: "t-right", source: "split", target: "right" }, + ...ends.flatMap((id) => [ + { id: `t-left-${id}`, source: "left", target: id }, + { id: `t-right-${id}`, source: "right", target: id }, + ]), + ], + }, + path: [], + }; +}; + +/** + * Sources each entering a small diamond of messages that meets again at its own activity, every diamond's entrance also leading into one shared run of messages. The run leads nowhere unless `livePart` is set, when its middle message also leads to one more activity. + */ +export const sharedSuffixShape = + (livePart: boolean) => + (count: number): Shape => { + const sources = ids(count, "s-"); + const run = ids(count, "run-"); + const middle = run[Math.floor(count / 2)] ?? "run-0"; + + return { + model: { + ...empty, + participants: actors, + elements: [ + ...sources.map((id) => activity(id, "customer")), + ...sources.flatMap((id) => + [`a-${id}`, `b-${id}`, `c-${id}`].map(messageElement), + ), + ...run.map(messageElement), + ...sources.map((id) => activity(`e-${id}`)), + ...(livePart ? [activity("tail")] : []), + ], + transitions: [ + ...sources.flatMap((id) => [ + { id: `t-${id}`, source: id, target: `a-${id}` }, + { id: `t-${id}-b`, source: `a-${id}`, target: `b-${id}` }, + { id: `t-${id}-c`, source: `a-${id}`, target: `c-${id}` }, + { id: `t-${id}-b-e`, source: `b-${id}`, target: `e-${id}` }, + { id: `t-${id}-c-e`, source: `c-${id}`, target: `e-${id}` }, + { id: `t-${id}-run`, source: `a-${id}`, target: "run-0" }, + ]), + ...run.slice(1).map((id, index) => ({ + id: `t-${id}`, + source: run[index] ?? id, + target: id, + })), + ...(livePart + ? [{ id: "t-tail", source: middle, target: "tail" }] + : []), + ], + }, + path: [], + }; + }; + +/** Sources all entering one message, or a run of `through` messages, that leads to as many activities as there are sources: folding it would draw every source to every activity. */ +export const fanThroughShape = + (through: number) => + (count: number): Shape => { + const sources = ids(count, "s-"); + const ends = ids(count, "e-"); + const run = ids(through, "m-"); + const first = run[0] ?? "m-0"; + const last = run.at(-1) ?? first; + + return { + model: { + ...empty, + participants: actors, + elements: [ + ...sources.map((id) => activity(id, "customer")), + ...run.map(messageElement), + ...ends.map((id) => activity(id)), + ], + transitions: [ + ...sources.map((id) => ({ + id: `t-${id}`, + source: id, + target: first, + })), + ...run.slice(1).map((id, index) => ({ + id: `t-${id}`, + source: run[index] ?? id, + target: id, + })), + ...ends.map((id) => ({ id: `t-${id}`, source: last, target: id })), + ], + }, + path: [], + }; + }; diff --git a/packages/core/graph/projection/src/test-support/work.ts b/packages/core/graph/projection/src/test-support/work.ts new file mode 100644 index 0000000..fc52cc2 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/work.ts @@ -0,0 +1,75 @@ +/** + * Counts the work a call does through the collection operations every projection is built from: each step of an array's iterator (every `for...of`, spread and destructuring), each `push`, each set or map insertion and each set or map lookup counts one, so walks over linked structures that test membership as they go are counted too, and each array scan (`filter`, `map`, `flatMap`, `some`, `every`, `find`, `findIndex`, `includes`, `indexOf`) counts the length of the array it scans, so a scan of a whole list per item shows as quadratic. The operations are wrapped only while `run` runs and restored afterwards, even when it throws. + * @param run - The call to measure. + * @returns The work counted. + */ +export const workOf = (run: () => unknown): number => { + let work = 0; + const restores: (() => void)[] = []; + const wrap = ( + target: object, + name: string, + cost: (self: unknown) => number, + ): void => { + const original: unknown = Reflect.get(target, name); + if (typeof original !== "function") { + throw new TypeError(`${name} is not a method`); + } + Reflect.defineProperty(target, name, { + configurable: true, + writable: true, + value: function counted( + this: unknown, + ...args: readonly unknown[] + ): unknown { + work += cost(this); + + return Reflect.apply(original, this, args); + }, + }); + restores.push(() => { + Reflect.defineProperty(target, name, { + configurable: true, + writable: true, + value: original, + }); + }); + }; + const one = (): number => 1; + const length = (self: unknown): number => + Array.isArray(self) ? self.length : 1; + for (const name of [ + "filter", + "map", + "flatMap", + "some", + "every", + "find", + "findIndex", + "includes", + "indexOf", + ]) { + wrap(Array.prototype, name, length); + } + wrap(Array.prototype, "push", one); + // Every `for...of`, spread and destructuring of an array steps its iterator, so each step counts once: a copy of a whole list made per item shows as quadratic, while taking its first item costs one. + const arrayIterator: unknown = Object.getPrototypeOf([][Symbol.iterator]()); + if (typeof arrayIterator !== "object" || arrayIterator === null) { + throw new TypeError("The array iterator has no prototype"); + } + wrap(arrayIterator, "next", one); + wrap(Set.prototype, "add", one); + wrap(Set.prototype, "has", one); + wrap(Map.prototype, "set", one); + wrap(Map.prototype, "get", one); + wrap(Map.prototype, "has", one); + try { + run(); + } finally { + for (const restore of restores.toReversed()) { + restore(); + } + } + + return work; +}; diff --git a/packages/core/graph/projection/test/package.smoke.test.ts b/packages/core/graph/projection/test/package.smoke.test.ts index 667bde7..e97466a 100644 --- a/packages/core/graph/projection/test/package.smoke.test.ts +++ b/packages/core/graph/projection/test/package.smoke.test.ts @@ -24,5 +24,7 @@ test("the built package projects a model", () => { expect(handoffs(model)).toEqual([ { transition: "t1", from: "customer", to: "shop" }, ]); - expect(toFlowchart(model).edges).toEqual([{ from: "submit", to: "pack" }]); + expect(toFlowchart(model).edges).toEqual([ + { from: "submit", to: "pack", transitions: ["t1"] }, + ]); }); diff --git a/packages/core/graph/projection/tsconfig.build.json b/packages/core/graph/projection/tsconfig.build.json index 79dba2e..39f1470 100644 --- a/packages/core/graph/projection/tsconfig.build.json +++ b/packages/core/graph/projection/tsconfig.build.json @@ -10,6 +10,9 @@ "rewriteRelativeImportExtensions": true }, "references": [ + { + "path": "../dag/tsconfig.build.json" + }, { "path": "../schema/tsconfig.build.json" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 266e4f0..c8eab24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -580,6 +580,9 @@ importers: packages/core/graph/projection: dependencies: + '@exadev/process-graph-dag': + specifier: 1.0.2 + version: link:../dag '@exadev/process-graph-schema': specifier: 1.1.0 version: link:../schema