From 8ab73bf3fd0f562657cec5ea414cde1170b83dec Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 16:17:04 +0100 Subject: [PATCH 01/33] feat(projection): derive sequence fragments and read message elements toSequence now wraps messages in the fragments the model's structure implies as well as the authored ones: an alt or opt at a decision's forward branches, a loop for a decision transition whose target can reach the decision again (the graph-domain rule), and a par at a fork. Each derived fragment names the decision or fork it comes from; one holding exactly an authored fragment's messages is not drawn. A step leaving an element with no actor, such as a fork or join, is sent by whoever reached it, so parallel branches draw their messages. Message elements are read by every projection instead of being reported as dropped: a sequence step arriving at one draws it with its own ends, label and mode; a flowchart edge, hand-off and state machine transition carry it when they pass through it, the state machine taking its event as the trigger. Flowchart edges and state machine transitions list the model transitions they stand for, so a view edit can map back. --- packages/core/graph/projection/src/chains.ts | 67 +++ .../graph/projection/src/derived-fragments.ts | 154 +++++++ .../core/graph/projection/src/flowchart.ts | 81 +++- .../core/graph/projection/src/handoffs.ts | 31 +- packages/core/graph/projection/src/loss.ts | 25 +- .../projection/src/order-views.unit.test.ts | 410 ++++++++++++++++++ .../projection/src/projections.unit.test.ts | 76 +++- .../core/graph/projection/src/sequence.ts | 247 ++++++++--- .../projection/src/sequence.unit.test.ts | 67 ++- .../graph/projection/src/state-machine.ts | 139 +++--- .../projection/src/state-machine.unit.test.ts | 32 +- .../core/graph/projection/src/swimlane.ts | 2 +- .../src/test-support/order-variants.ts | 310 +++++++++++++ .../projection/test/package.smoke.test.ts | 4 +- 14 files changed, 1442 insertions(+), 203 deletions(-) create mode 100644 packages/core/graph/projection/src/chains.ts create mode 100644 packages/core/graph/projection/src/derived-fragments.ts create mode 100644 packages/core/graph/projection/src/order-views.unit.test.ts create mode 100644 packages/core/graph/projection/src/test-support/order-variants.ts diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts new file mode 100644 index 0000000..ebbb400 --- /dev/null +++ b/packages/core/graph/projection/src/chains.ts @@ -0,0 +1,67 @@ +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 run of transitions from a drawn element to the next drawn element, passing through elements the notation folds away. */ +export interface Chain { + /** The transitions in order; the first leaves the start element and the last arrives at `target`. */ + readonly transitions: readonly TransitionOf[]; + /** The folded elements passed through, in order. Empty when the chain is a single transition. */ + readonly through: readonly I["element"][]; + readonly target: I["element"]; +} + +/** + * Every chain that starts with `first` and continues through folded elements until it reaches an element that is not folded. A folded element already passed on the same chain ends it there, so a cycle among folded elements yields a chain whose target is that folded element, which the caller does not draw. + * + * Chains come out in depth-first order over each element's outgoing transitions in model order. The walk uses an explicit stack, so depth is bounded by memory. + * @param first - The transition the chains start with. + * @param outgoing - The transitions leaving each element. + * @param folded - Whether the notation folds an element away instead of drawing it. + */ +export const chainsFrom = ( + first: TransitionOf, + outgoing: ReadonlyMap[]>, + folded: (element: I["element"]) => boolean, +): readonly Chain[] => { + const chains: Chain[] = []; + // Each entry is a chain walked so far; its `target` is where the last transition arrives. + const stack: Chain[] = [ + { transitions: [first], through: [], target: first.target }, + ]; + for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + const { target } = next; + if (!folded(target) || next.through.includes(target)) { + chains.push(next); + continue; + } + const through = [...next.through, target]; + const onward = outgoing.get(target) ?? []; + for (const transition of onward.toReversed()) { + stack.push({ + transitions: [...next.transitions, transition], + through, + target: transition.target, + }); + } + } + + return chains; +}; diff --git a/packages/core/graph/projection/src/derived-fragments.ts b/packages/core/graph/projection/src/derived-fragments.ts new file mode 100644 index 0000000..b52bcd5 --- /dev/null +++ b/packages/core/graph/projection/src/derived-fragments.ts @@ -0,0 +1,154 @@ +import type { + FragmentOperator, + IdSpace, + Model, +} from "@exadev/process-graph-schema"; +import { outgoingMap, type TransitionOf } from "./chains.ts"; + +/** One operand of a fragment the projection derives from the model's structure: the guard of the branch it follows and the elements of that branch on the scenario path. */ +export interface DerivedOperand { + readonly guard: string | undefined; + readonly body: readonly I["element"][]; +} + +/** A fragment read off the model's structure rather than authored: an `alt` or `opt` from a decision's branches, a `loop` from a decision's way back to earlier work, a `par` from a fork's branches. */ +export interface DerivedFragment { + /** The decision or fork the fragment comes from. */ + readonly element: I["element"]; + readonly operator: FragmentOperator; + readonly operands: readonly DerivedOperand[]; +} + +/** 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; + +/** Elements reachable from `start` by following transitions, `start` included. The walk uses an explicit stack. */ +const reachable = ( + start: I["element"], + outgoing: ReadonlyMap[]>, + skip: ReadonlySet, +): ReadonlySet => { + const seen = new Set([start]); + const stack: I["element"][] = [start]; + for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + for (const transition of outgoing.get(next) ?? []) { + if (!skip.has(transition.id) && !seen.has(transition.target)) { + seen.add(transition.target); + stack.push(transition.target); + } + } + } + + return seen; +}; + +/** + * The fragments the model's structure implies, restricted to the elements on the scenario path. + * + * A transition leaving a decision is a loop transition when its target can reach the decision again, the same rule `graph-domain` uses for a legal cycle. Each loop transition yields a `loop` whose body is every element on a cycle through it, guarded by the loop transition's guard. The decision's other (forward) branches, when there are at least two, yield an `alt` with one operand per branch; when there are exactly two and one of them goes straight to where the branches meet, the fragment is an `opt` around the other. A fork pseudo-state's branches yield a `par`. A branch's body is the elements reachable from it, over forward transitions only, that are not reachable from every branch: those that every branch reaches lie after the branches meet. + * @param model - The model the scenario runs through. + * @param onPath - The elements the scenario passes, to which every body is restricted. + * @returns Derived fragments in model order of the decision or fork they come from, each decision's loops before its alternatives. + */ +export const deriveFragments = ( + model: Model, + onPath: ReadonlySet, +): readonly DerivedFragment[] => { + const outgoing = outgoingMap(model); + const none = new Set(); + const reaches = (from: I["element"], to: I["element"]): boolean => + reachable(from, outgoing, none).has(to); + + const loops = new Set(); + for (const element of model.elements) { + if (element.kind !== "decision") { + continue; + } + for (const transition of outgoing.get(element.id) ?? []) { + if (reaches(transition.target, element.id)) { + loops.add(transition.id); + } + } + } + + const onPathOnly = (elements: readonly I["element"][]): I["element"][] => + elements.filter((id) => onPath.has(id)); + + /** One operand per branch, with the branches' shared tail removed, and whether each branch goes straight to where the branches meet. */ + const branchesOf = ( + branches: readonly TransitionOf[], + ): readonly { + readonly operand: DerivedOperand; + readonly direct: boolean; + }[] => { + const reach = branches.map((branch) => ({ + branch, + elements: reachable(branch.target, outgoing, loops), + })); + // An element every branch reaches lies after the branches meet; it is enough to test those the first branch reaches. + const [head] = reach; + const shared = new Set( + head === undefined + ? [] + : [...head.elements].filter((id) => + reach.every(({ elements }) => elements.has(id)), + ), + ); + + return reach.map(({ branch, elements }) => ({ + operand: { + guard: branch.guard, + body: onPathOnly([...elements].filter((id) => !shared.has(id))), + }, + direct: shared.has(branch.target), + })); + }; + + const derived: DerivedFragment[] = []; + for (const element of model.elements) { + const leaving = outgoing.get(element.id) ?? []; + if (element.kind === "decision") { + for (const loop of leaving.filter((transition) => + loops.has(transition.id), + )) { + const body = [...reachable(loop.target, outgoing, none)].filter( + (id) => id === element.id || reaches(id, element.id), + ); + derived.push({ + element: element.id, + operator: "loop", + operands: [{ guard: loop.guard, body: onPathOnly(body) }], + }); + } + const forward = leaving.filter((transition) => !loops.has(transition.id)); + if (forward.length < minBranches) { + continue; + } + const branches = branchesOf(forward); + const [taken, ...others] = branches.filter(({ direct }) => !direct); + derived.push( + branches.length === minBranches && + taken !== undefined && + others.length === 0 + ? { element: element.id, operator: "opt", operands: [taken.operand] } + : { + element: element.id, + operator: "alt", + operands: branches.map(({ operand }) => operand), + }, + ); + } else if ( + element.kind === "pseudo" && + element.role === "fork" && + leaving.length >= minBranches + ) { + derived.push({ + element: element.id, + operator: "par", + operands: branchesOf(leaving).map(({ operand }) => operand), + }); + } + } + + return derived; +}; diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index 83f5812..bfdec3c 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -1,5 +1,6 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; +import { chainsFrom, outgoingMap, type Chain } from "./chains.ts"; import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface FlowNode { @@ -8,10 +9,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"]; + /** The guards along the edge joined with "and", or, when there are none, the labels of the messages it carries. */ readonly label?: string; + /** The model transitions the edge stands for, in order. An edit to the edge maps back to the model through these. */ + readonly transitions: readonly I["transition"][]; + /** The `message` elements the edge carries, in order, when it stands for a chain. */ + readonly through?: readonly I["element"][]; } export interface Flowchart { @@ -55,29 +62,68 @@ const flowNode = ( * 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 flow - The nodes and edges the projection drew; a message element an edge carries is shown. */ export const flowchartLoss = ( model: Model, notation: "flowchart" | "swimlane", - nodes: readonly FlowNode[], + flow: Readonly, "nodes" | "edges">>, ): readonly Loss[] => [ - ...commonLoss(model, notation), + ...commonLoss(model, notation, { + fragments: new Set(), + messages: new Set(flow.edges.flatMap((edge) => edge.through ?? [])), + }), ...unrepresentedLoss( model, notation, - new Set(nodes.map((node) => node.id)), + new Set(flow.nodes.map((node) => node.id)), ), ]; +/** The edge a chain of transitions draws: guards joined, or the carried messages' labels when no transition on it is guarded. */ +const edgeOf = ( + from: I["element"], + chain: Chain, + messageLabels: ReadonlyMap, +): FlowEdge => { + const guards = chain.transitions.flatMap((transition) => + transition.guard === undefined ? [] : [transition.guard], + ); + const carried = chain.through.flatMap((id) => { + const label = messageLabels.get(id); + + return label === undefined ? [] : [label]; + }); + const label = + guards.length > 0 + ? guards.join(" and ") + : carried.length > 0 + ? carried.join(", ") + : undefined; + + return { + from, + to: chain.target, + ...(label === undefined ? {} : { label }), + transitions: chain.transitions.map((transition) => transition.id), + ...(chain.through.length === 0 ? {} : { through: chain.through }), + }; +}; + /** - * 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. + * 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`). 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 => { 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 +131,23 @@ export const toFlowchart = ( } const included = new Set(nodes.map((node) => node.id)); + const outgoing = outgoingMap(model); + const isMessage = (id: I["element"]): boolean => messageLabels.has(id); 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, - }, - ); + if (!included.has(transition.source)) { + continue; + } + for (const chain of chainsFrom(transition, outgoing, isMessage)) { + if (included.has(chain.target)) { + edges.push(edgeOf(transition.source, chain, messageLabels)); + } } } - return { nodes, edges, loss: flowchartLoss(model, "flowchart", nodes) }; + return { + nodes, + edges, + loss: flowchartLoss(model, "flowchart", { nodes, edges }), + }; }; diff --git a/packages/core/graph/projection/src/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts index 5cb5536..526ddce 100644 --- a/packages/core/graph/projection/src/handoffs.ts +++ b/packages/core/graph/projection/src/handoffs.ts @@ -1,26 +1,49 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; +import { chainsFrom, outgoingMap } 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, in order, when it is carried by a message. */ + 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 outgoing = outgoingMap(model); + const isMessage = (id: I["element"]): boolean => messages.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 chainsFrom(transition, outgoing, isMessage)) { + const to = actorOf.get(chain.target); + if (to !== undefined && from !== to) { + found.push({ + transition: transition.id, + from, + to, + ...(chain.through.length === 0 ? {} : { through: chain.through }), + }); + } } } diff --git a/packages/core/graph/projection/src/loss.ts b/packages/core/graph/projection/src/loss.ts index d8fdbdc..5892b01 100644 --- a/packages/core/graph/projection/src/loss.ts +++ b/packages/core/graph/projection/src/loss.ts @@ -22,7 +22,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 +59,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. @@ -95,11 +95,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 +124,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/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..b050ffb 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -1,11 +1,12 @@ 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 { deriveFragments } from "./derived-fragments.ts"; import { commonLoss, type Loss } from "./loss.ts"; export interface SequenceMessage { @@ -14,6 +15,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. */ + 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,23 +27,32 @@ 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[]; @@ -61,23 +75,42 @@ interface Container { readonly children: Placed[]; } -/** An authored fragment the scenario passes through, with a container for the fragments nested inside each of its operands. */ +/** A fragment that may be drawn: authored or derived. */ +interface Candidate { + /** The authored fragment's id; absent for a derived fragment. */ + readonly authored: I["fragment"] | undefined; + /** Builds the item drawn for it from the operands the scenario takes. */ + readonly item: (operands: readonly SequenceOperand[]) => SequenceItem; + readonly operands: readonly { + readonly guard?: string | undefined; + readonly body: readonly I["element"][]; + }[]; +} + +/** A fragment the scenario passes through, with a container for the fragments nested inside each of its operands. */ interface Placed { - readonly fragment: Fragment; + readonly candidate: Candidate; readonly operands: readonly TakenOperand[]; readonly indices: ReadonlySet; readonly nested: readonly Container[]; } +/** Whether two sets of message positions are the same. */ +const sameIndices = ( + first: ReadonlySet, + second: ReadonlySet, +): boolean => + first.size === second.size && [...first].every((index) => second.has(index)); + /** - * Wraps the scenario's messages in the authored fragments they fall inside. + * 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 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 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 their own 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. A derived fragment holding exactly the messages an authored one holds is not drawn either: the authored fragment is the author's notation for the same structure. * * A message belongs to an operand when the element it arrives at is in that operand's body. */ const wrapFragments = ( - fragments: readonly Fragment[], + fragments: readonly Candidate[], entries: readonly Entry[], onPath: ReadonlySet, ): { @@ -109,9 +142,20 @@ const wrapFragments = ( } const first = Math.min(...claimed); const last = Math.max(...claimed); - if (operands.length > 0 && last - first + 1 === claimed.size) { + const duplicate = + fragment.authored === undefined && + candidates.some( + (placed) => + placed.candidate.authored !== undefined && + sameIndices(placed.indices, claimed), + ); + if ( + operands.length > 0 && + last - first + 1 === claimed.size && + !duplicate + ) { candidates.push({ - fragment, + candidate: fragment, operands, indices: claimed, nested: operands.map(() => ({ children: [] })), @@ -161,16 +205,20 @@ const wrapFragments = ( 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 ?? []), - })), - }); + if (owner.candidate.authored !== undefined) { + drawn.add(owner.candidate.authored); + } + items.push( + owner.candidate.item( + owner.operands.map((operand, slot) => ({ + ...(operand.guard === undefined ? {} : { guard: operand.guard }), + items: itemsOf( + operand.entries, + owner.nested[slot]?.children ?? [], + ), + })), + ), + ); } } @@ -181,64 +229,122 @@ const wrapFragments = ( }; /** - * 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`. + * 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 => { 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 messages: SequenceMessage[] = []; const entries: Entry[] = []; + 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 (!lifelines.includes(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); + 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 +361,38 @@ export const toSequence = ( (transition) => !taken.has(transition.id) && onPath.has(transition.source), ); - const { items, drawn } = wrapFragments(model.fragments, entries, onPath); + const candidates: Candidate[] = [ + ...model.fragments.map(({ id, operator, operands }): Candidate => ({ + authored: id, + operands, + item: (drawnOperands) => ({ + kind: "fragment", + fragment: id, + operator, + operands: drawnOperands, + }), + })), + ...deriveFragments(model, onPath).map( + ({ element, operator, operands }): Candidate => ({ + authored: undefined, + operands, + item: (drawnOperands) => ({ + kind: "fragment", + element, + operator, + operands: drawnOperands, + }), + }), + ), + ]; + const { items, drawn } = wrapFragments(candidates, entries, onPath); - const loss: Loss[] = [...commonLoss(model, "sequence", drawn)]; + const loss: Loss[] = [ + ...commonLoss(model, "sequence", { + fragments: drawn, + messages: shownMessages, + }), + ]; if (offPath.length > 0) { loss.push({ kind: "scenario", 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..b5ddb20 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -1,5 +1,6 @@ -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 { chainsFrom, outgoingMap, type Chain } from "./chains.ts"; import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface StateMachineState { @@ -14,6 +15,10 @@ export interface StateMachineTransition { readonly event?: I["event"]; readonly guard?: string; readonly effect?: I["action"]; + /** The model transitions this transition stands for, in order. An edit to it maps back to the model through these. */ + readonly transitions: readonly I["transition"][]; + /** The elements folded into it, in order: decisions, activities that do not affect the subject, and `message` elements. */ + readonly through?: readonly I["element"][]; } export interface StateMachine { @@ -53,9 +58,17 @@ const stateOf = ( * * 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. + * * 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, @@ -71,81 +84,68 @@ 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 isFolded = (id: I["element"]): boolean => { + const element = elements.get(id); - const joinGuards = ( - first: string | undefined, - second: string | undefined, - ): string | undefined => { - if (first === undefined) { - return second; - } - - return second === undefined ? first : `${first} and ${second}`; + return element !== undefined && isTransparent(element); }; - 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, - }); - } + /** The transition a chain draws. The event is the last trigger along it, counting a folded message's own event at its place in the chain; guards are joined in order; the effect is the entry action of the target activity, or else the last effect along the chain. */ + const transitionOf = ( + source: I["element"], + chain: Chain, + ): StateMachineTransition => { + const triggers: I["event"][] = []; + const guards: string[] = []; + const effects: I["action"][] = []; + for (const [index, transition] of chain.transitions.entries()) { + if (transition.trigger !== undefined) { + triggers.push(transition.trigger); + } + if (transition.guard !== undefined) { + guards.push(transition.guard); + } + if (transition.effect !== undefined) { + effects.push(transition.effect); + } + // `through[index]` is the folded element this transition arrives at; the last transition arrives at the target instead. + const passed = chain.through[index]; + const folded = passed === undefined ? undefined : elements.get(passed); + if (folded?.kind === "message" && folded.event !== undefined) { + triggers.push(folded.event); } - - 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, - }, - ]; + const target = elements.get(chain.target); + const event = triggers.at(-1); + const guard = guards.length > 0 ? guards.join(" and ") : undefined; + const effect = + (target?.kind === "activity" ? target.action : undefined) ?? + effects.at(-1); + + return { + source, + target: chain.target, + ...(event === undefined ? {} : { event }), + ...(guard === undefined ? {} : { guard }), + ...(effect === undefined ? {} : { effect }), + transitions: chain.transitions.map((transition) => transition.id), + ...(chain.through.length === 0 ? {} : { through: chain.through }), + }; }; const states: StateMachineState[] = []; @@ -166,23 +166,22 @@ 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; + for (const chain of chainsFrom(transition, outgoing, isFolded)) { + if (stateIds.has(chain.target)) { + transitions.push(transitionOf(element.id, chain)); } - 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 }), - }); } } } + const carried = new Set( + transitions.flatMap((transition) => transition.through ?? []), + ); const loss: Loss[] = [ - ...commonLoss(model, "stateMachine"), + ...commonLoss(model, "stateMachine", { + fragments: new Set(), + messages: carried, + }), ...unrepresentedLoss( model, "stateMachine", 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..b249fc1 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -64,6 +64,6 @@ export const toSwimlane = (model: Model): Swimlane => { lanes, unassigned, handoffs: handoffs(model), - loss: flowchartLoss(model, "swimlane", flow.nodes), + loss: flowchartLoss(model, "swimlane", flow), }; }; 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/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"] }, + ]); }); From 43d5550da29a32c1b5f18c8037c6ffb76265bdd0 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 16:32:31 +0100 Subject: [PATCH 02/33] feat(projection): turn an edited view back into a diff against the model diffFlowchart, diffSwimlane, diffStateMachine and diffSequence take a view after an edit and return a ModelDiff: added, removed, renamed, relaned and readdressed elements, added, removed, rewired and changed transitions, and renamed events, matched by identifier. A change the view does not determine is marked inferred: a new terminator's or bar's role, an edit to a state machine transition that stands for a chain through a folded decision, and what a sequence diagram implies about steps and ownership. confirmChanges confirms inferred changes, and applyModelDiff applies a diff once nothing inferred is left, throwing UnconfirmedChangeError otherwise. Edits the model cannot express throw rather than being dropped. --- packages/core/graph/projection/src/diff.ts | 375 +++++++++++++ packages/core/graph/projection/src/index.ts | 18 +- .../graph/projection/src/inverse-flowchart.ts | 315 +++++++++++ .../graph/projection/src/inverse-sequence.ts | 225 ++++++++ .../projection/src/inverse-state-machine.ts | 224 ++++++++ .../graph/projection/src/inverse.unit.test.ts | 508 ++++++++++++++++++ .../core/graph/projection/src/sequence.ts | 2 +- 7 files changed, 1665 insertions(+), 2 deletions(-) create mode 100644 packages/core/graph/projection/src/diff.ts create mode 100644 packages/core/graph/projection/src/inverse-flowchart.ts create mode 100644 packages/core/graph/projection/src/inverse-sequence.ts create mode 100644 packages/core/graph/projection/src/inverse-state-machine.ts create mode 100644 packages/core/graph/projection/src/inverse.unit.test.ts diff --git a/packages/core/graph/projection/src/diff.ts b/packages/core/graph/projection/src/diff.ts new file mode 100644 index 0000000..87d1b8a --- /dev/null +++ b/packages/core/graph/projection/src/diff.ts @@ -0,0 +1,375 @@ +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 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] : [], + ), + ); + + const elements = [ + ...model.elements + .filter((element) => !removedElements.has(element.id)) + .map((element) => + diff.elements + .filter((change) => subjectOf(change) === 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) => + diff.transitions + .filter((change) => transitionSubjectOf(change) === transition.id) + .reduce(applyTransitionChange, transition), + ), + ...diff.transitions.flatMap((change) => + change.kind === "added" ? [change.transition] : [], + ), + ]; + const events = model.events.map((event) => + diff.events + .filter((change) => change.event === 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/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..fbb75fb --- /dev/null +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -0,0 +1,315 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import type { ElementChange, ModelDiff, TransitionChange } from "./diff.ts"; +import { toFlowchart, type FlowEdge, type FlowNode } from "./flowchart.ts"; +import type { Swimlane } from "./swimlane.ts"; + +/** The key an edge is matched by across the original and the edited view: the transitions it stands for. */ +const edgeKey = (edge: FlowEdge): string => + JSON.stringify(edge.transitions); + +/** 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, + edges: readonly FlowEdge[], + 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: edges.some((edge) => edge.to === node.id) ? "final" : "initial", + }, + inferred: true, + }; + } + const ins = edges.filter((edge) => edge.to === node.id).length; + const outs = edges.filter((edge) => edge.from === node.id).length; + + 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 original = toFlowchart(model); + 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 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, edited.edges, 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 originalEdges = new Map>( + original.edges.map((edge) => [edgeKey(edge), edge]), + ); + const editedKeys = new Set(edited.edges.map(edgeKey)); + const transitions = new Map( + model.transitions.map((transition) => [transition.id, transition]), + ); + for (const edge of original.edges) { + if (!editedKeys.has(edgeKey(edge))) { + // Removing an edge that carries a message removes every step of it, which leaves the message element unconnected: a choice, so inferred. + for (const transition of edge.transitions) { + transitionChanges.push({ + kind: "removed", + transition, + inferred: edge.transitions.length > 1, + }); + } + } + } + for (const edge of edited.edges) { + const before = originalEdges.get(edgeKey(edge)); + const [first, ...rest] = edge.transitions; + if (before === undefined) { + if ( + first === undefined || + rest.length > 0 || + transitions.has(first) || + edge.through !== undefined + ) { + throw new Error( + `Edge ${edgeKey(edge)} 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 last = edge.transitions.at(-1); + const head = first === undefined ? undefined : transitions.get(first); + const tail = last === undefined ? undefined : transitions.get(last); + if (head === undefined || tail === undefined) { + continue; + } + const chain = edge.transitions.length > 1; + if (!chain && (before.from !== edge.from || before.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, + }); + } else { + if (before.from !== 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 (before.to !== edge.to) { + transitionChanges.push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edge.to }, + inferred: false, + }); + } + } + if (before.label !== edge.label) { + if (chain) { + throw new Error( + `Edge ${edgeKey(edge)} carries a message; edit the message or its steps rather than the edge's label`, + ); + } + transitionChanges.push({ + kind: "changed", + field: "guard", + transition: head.id, + from: head.guard, + to: edge.label, + inferred: false, + }); + } + } + + 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 the transitions it stands for: a new edge must carry one new transition id and is an added transition; a projected edge missing from the edit removes its transitions; moved ends rewire the transition (for an edge that carries a message, the first step's source or the last step's target); a changed label changes the guard. + * @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, or a relabelled edge that carries a message. + */ +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..b39b146 --- /dev/null +++ b/packages/core/graph/projection/src/inverse-sequence.ts @@ -0,0 +1,225 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import { actorMap } from "./actors.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 Map[]>(); + for (const message of original.messages) { + pending.set(message.transition, [ + ...(pending.get(message.transition) ?? []), + message, + ]); + } + + const elementChanges: ElementChange[] = []; + const transitionChanges: TransitionChange[] = []; + const eventChanges: EventChange[] = []; + /** Where each edited message arrives, by position, for placing a new step after it. */ + const arrivals: { readonly actor: I["actor"]; readonly at: I["element"] }[] = + []; + /** 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 queue = pending.get(message.transition); + const before = queue?.shift(); + 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.findLast( + ({ actor }) => actor === message.from, + )?.at; + 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.push({ actor: message.to, at: 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.push({ + actor: message.to, + at: 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, left] of pending) { + if (left.length > 0) { + 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..11cf759 --- /dev/null +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -0,0 +1,224 @@ +import type { IdSpace, Model } from "@exadev/process-graph-schema"; +import type { ElementOf } from "./actors.ts"; +import type { ElementChange, ModelDiff, TransitionChange } from "./diff.ts"; +import { + toStateMachine, + type StateMachine, + type StateMachineState, + type StateMachineTransition, +} from "./state-machine.ts"; + +/** The key a transition is matched by across the original and the edited machine: the model transitions it stands for. */ +const transitionKey = ( + transition: StateMachineTransition, +): string => JSON.stringify(transition.transitions); + +/** 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 the model transitions it stands for. 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 the last step 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 original = toStateMachine(model, edited.subject); + 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]), + ); + const originals = new Map>( + original.transitions.map((transition) => [ + transitionKey(transition), + transition, + ]), + ); + const editedKeys = new Set(edited.transitions.map(transitionKey)); + const transitionChanges: TransitionChange[] = []; + for (const drawnTransition of original.transitions) { + const last = drawnTransition.transitions.at(-1); + if (last !== undefined && !editedKeys.has(transitionKey(drawnTransition))) { + transitionChanges.push({ + kind: "removed", + transition: last, + inferred: drawnTransition.transitions.length > 1, + }); + } + } + for (const edit of edited.transitions) { + const before = originals.get(transitionKey(edit)); + const [first, ...rest] = edit.transitions; + if (before === undefined) { + if ( + first === undefined || + rest.length > 0 || + transitions.has(first) || + edit.through !== undefined + ) { + throw new Error( + `Transition ${transitionKey(edit)} is neither a transition of the model nor a new transition with a single new id`, + ); + } + transitionChanges.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 head = first === undefined ? undefined : transitions.get(first); + const lastId = edit.transitions.at(-1); + const tail = lastId === undefined ? undefined : transitions.get(lastId); + if (head === undefined || tail === undefined) { + continue; + } + const chain = edit.transitions.length > 1; + if ( + !chain && + (before.source !== edit.source || before.target !== edit.target) + ) { + transitionChanges.push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edit.source, target: edit.target }, + inferred: false, + }); + } else { + if (before.source !== edit.source) { + transitionChanges.push({ + kind: "rewired", + transition: head.id, + from: { source: head.source, target: head.target }, + to: { source: edit.source, target: head.target }, + inferred: false, + }); + } + if (before.target !== edit.target) { + transitionChanges.push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edit.target }, + inferred: true, + }); + } + } + if (before.event !== edit.event) { + transitionChanges.push({ + kind: "changed", + field: "trigger", + transition: tail.id, + from: tail.trigger, + to: edit.event, + inferred: chain, + }); + } + if (before.guard !== edit.guard) { + transitionChanges.push({ + kind: "changed", + field: "guard", + transition: tail.id, + from: tail.guard, + to: edit.guard, + inferred: chain, + }); + } + if (before.effect !== edit.effect) { + const target = elements.get(before.target); + if (target?.kind === "activity" && target.action !== undefined) { + throw new Error( + `The effect of ${transitionKey(edit)} is the action of "${target.id}"; change that activity's action instead`, + ); + } + transitionChanges.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/sequence.ts b/packages/core/graph/projection/src/sequence.ts index b050ffb..5e6f33b 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -15,7 +15,7 @@ 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. */ + /** 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; From aa2b71b9b296efe19b4272db66bfe3c2904daeaf Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 16:32:54 +0100 Subject: [PATCH 03/33] docs(projection): document message elements, derived fragments and view inverses --- packages/core/graph/projection/README.md | 60 ++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 1d01796..60ee97a 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,13 @@ 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. + +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. ## Swimlane lanes @@ -33,11 +39,26 @@ 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, restricted to the scenario path: + +| Structure | Fragment | Operands | +| --------------------------------------------------------------------------- | -------- | --------------------------------------- | +| A decision's transition whose target can reach the decision again | `loop` | One, guarded by that transition's guard | +| A decision's other (forward) branches, when there are at least two | `alt` | One per branch, guarded by the branch | +| Exactly two forward branches, one of them going straight to where they meet | `opt` | The branch that does something | +| A fork pseudo-state's branches | `par` | One per branch | + +A loop transition is the same one `graph-domain` treats as a legal cycle. A loop's body is every element on a cycle through it; a branch's body is what it reaches, over forward transitions, that not every branch reaches (what every branch reaches lies after they meet). 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. -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 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 an authored one left out is reported as a `fragments` loss. ## Loss reports @@ -47,7 +68,7 @@ Every projection result carries `loss`, what it did not carry over from the mode | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | | `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 | +| `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`, 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` | @@ -57,6 +78,35 @@ Every projection result carries `loss`, what it did not carry over from the mode 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 state machine transition that stands for a chain through a folded decision (its later steps are shared with other chains), 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. + +`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), +); +``` + ## Commands ```sh From 37a8d4003c3ee2d663697eae6d75ba645326dae5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 16:35:14 +0100 Subject: [PATCH 04/33] refactor(projection): keep the derived operand type internal --- packages/core/graph/projection/src/derived-fragments.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/graph/projection/src/derived-fragments.ts b/packages/core/graph/projection/src/derived-fragments.ts index b52bcd5..ab050a7 100644 --- a/packages/core/graph/projection/src/derived-fragments.ts +++ b/packages/core/graph/projection/src/derived-fragments.ts @@ -6,7 +6,7 @@ import type { import { outgoingMap, type TransitionOf } from "./chains.ts"; /** One operand of a fragment the projection derives from the model's structure: the guard of the branch it follows and the elements of that branch on the scenario path. */ -export interface DerivedOperand { +interface DerivedOperand { readonly guard: string | undefined; readonly body: readonly I["element"][]; } From b7979cbf07625075bb66cebee7ffa613c9754943 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 17:20:18 +0100 Subject: [PATCH 05/33] fix(projection): collapse routes through folded elements instead of enumerating them chainsFrom walked every distinct route through folded elements, so a layered acyclic graph of message elements produced a number of chains, flowchart edges, hand-offs and state machine transitions exponential in its depth. It now walks depth-first with a visited set: each folded element is entered once per starting transition and each transition leaving it is followed once. There is one chain per element reached, whose transitions and through are the union over every route to it, each once in the order first met. A transition back to a folded element still on the walk's path closes a cycle and ends there, as before. A merged chain has no single route, so its guards, triggers and effects are not joined: a flowchart edge is labelled with the messages it carries and a state machine transition keeps only the target's entry action. The inverses act on every step arriving at a chain's end rather than the last transition listed. --- packages/core/graph/projection/README.md | 4 +- packages/core/graph/projection/src/chains.ts | 134 ++++++++++-- .../graph/projection/src/chains.unit.test.ts | 205 ++++++++++++++++++ .../core/graph/projection/src/flowchart.ts | 12 +- .../graph/projection/src/inverse-flowchart.ts | 37 ++-- .../projection/src/inverse-state-machine.ts | 70 +++--- .../graph/projection/src/state-machine.ts | 22 +- 7 files changed, 402 insertions(+), 82 deletions(-) create mode 100644 packages/core/graph/projection/src/chains.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 60ee97a..3b7e3c2 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -29,6 +29,8 @@ A `message` element is read by every projection. A sequence step arriving at one 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. The walk enters each folded element once per starting transition and follows each transition leaving it once, and each target's union is gathered by walking back once over the transitions met, so the work grows with the elements and transitions reached and the number of targets, never with the number of routes; a transition back to a folded element still on the walk's path closes a cycle and ends there. On a single route the guards are joined with "and" and the last trigger and effect are kept; where several routes are merged their guards, triggers and effects belong to different routes, so a flowchart edge is labelled only with the messages it carries and a state machine transition keeps only the target's entry action. + ## Swimlane lanes `toSwimlane` has a lane for every declared role or system, in declaration order, with empty `nodes` when nothing is assigned to it, so a participant that has not started yet is still visible. Entities are not actors and get no lane. An actor that a runtime model names without declaring still gets a lane, after the declared ones, and `validate` reports it. @@ -89,7 +91,7 @@ Each view has an inverse that takes the view after an edit and returns a `ModelD | `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 state machine transition that stands for a chain through a folded decision (its later steps are shared with other chains), 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. +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 or moving its target acts on each step arriving at its end), 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. `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. diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index ebbb400..88ff5ca 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -19,49 +19,137 @@ export const outgoingMap = ( return outgoing; }; -/** A run of transitions from a drawn element to the next drawn element, passing through elements the notation folds away. */ +/** + * Everything that leads from one transition to one element through folded elements: from a drawn element to the next drawn element, or, when the folded elements form a cycle, to the folded element the cycle returns to. Several routes from the same first transition to the same target are one chain. + */ export interface Chain { - /** The transitions in order; the first leaves the start element and the last arrives at `target`. */ + /** Every transition on some route from the first transition to `target`, each once, in the order the walk first met them. The first transition comes first. */ readonly transitions: readonly TransitionOf[]; - /** The folded elements passed through, in order. Empty when the chain is a single transition. */ + /** Every folded element on some route to `target`, each once, in the order the walk first met them. Empty when the chain is a single transition. */ readonly through: readonly I["element"][]; readonly target: I["element"]; + /** Whether more than one route leads to `target`, so `transitions` is a union of routes rather than one route in order. */ + readonly merged: boolean; } /** - * Every chain that starts with `first` and continues through folded elements until it reaches an element that is not folded. A folded element already passed on the same chain ends it there, so a cycle among folded elements yields a chain whose target is that folded element, which the caller does not draw. + * The chains that start with `first`: one for each element it leads to through folded elements, in the order the walk first reaches them. + * + * The walk is depth-first over each element's outgoing transitions in model order, with an explicit stack and a visited set, so each folded element is entered once and each transition leaving one is followed once, and `folded` is asked once per element reached. Routes through different folded elements that reach the same target are collapsed into one chain rather than enumerated, so the number of chains is at most the number of elements reached, whatever the number of routes. A transition that arrives back at a folded element still on the walk's stack closes a cycle; that element is then also a chain target, which no notation draws because it is folded. * - * Chains come out in depth-first order over each element's outgoing transitions in model order. The walk uses an explicit stack, so depth is bounded by memory. + * After the walk, each target's `transitions` and `through` are found by walking back from it over the transitions met, so building every chain takes at most the number of targets times the number of transitions met. * @param first - The transition the chains start with. * @param outgoing - The transitions leaving each element. * @param folded - Whether the notation folds an element away instead of drawing it. + * @returns One chain per target, in the order the walk first reaches each target. */ export const chainsFrom = ( first: TransitionOf, outgoing: ReadonlyMap[]>, folded: (element: I["element"]) => boolean, ): readonly Chain[] => { - const chains: Chain[] = []; - // Each entry is a chain walked so far; its `target` is where the last transition arrives. - const stack: Chain[] = [ - { transitions: [first], through: [], target: first.target }, - ]; - for (let next = stack.pop(); next !== undefined; next = stack.pop()) { - const { target } = next; - if (!folded(target) || next.through.includes(target)) { - chains.push(next); + const isFolded = new Map(); + const foldedOnce = (element: I["element"]): boolean => { + const known = isFolded.get(element); + if (known !== undefined) { + return known; + } + const answer = folded(element); + isFolded.set(element, answer); + + return answer; + }; + + /** Folded elements entered, in order, and whether each is still on the stack (`open`) or finished. */ + const state = new Map(); + const entered: I["element"][] = []; + const met: TransitionOf[] = []; + const targets: I["element"][] = []; + const isTarget = new Set(); + const addTarget = (element: I["element"]): void => { + if (!isTarget.has(element)) { + isTarget.add(element); + targets.push(element); + } + }; + const arrive = (transition: TransitionOf): boolean => { + met.push(transition); + const { target } = transition; + if (!foldedOnce(target)) { + addTarget(target); + + return false; + } + const seen = state.get(target); + if (seen === "open") { + addTarget(target); + } + if (seen !== undefined) { + return false; + } + state.set(target, "open"); + entered.push(target); + + return true; + }; + + const stack: { readonly element: I["element"]; next: number }[] = []; + if (arrive(first)) { + stack.push({ element: first.target, next: 0 }); + } + for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) { + const transition = (outgoing.get(top.element) ?? [])[top.next]; + if (transition === undefined) { + state.set(top.element, "done"); + stack.pop(); continue; } - const through = [...next.through, target]; - const onward = outgoing.get(target) ?? []; - for (const transition of onward.toReversed()) { - stack.push({ - transitions: [...next.transitions, transition], - through, - target: transition.target, - }); + top.next += 1; + if (arrive(transition)) { + stack.push({ element: transition.target, next: 0 }); + } + } + + const incoming = new Map[]>(); + for (const transition of met) { + const into = incoming.get(transition.target); + if (into === undefined) { + incoming.set(transition.target, [transition]); + } else { + into.push(transition); } } - return chains; + return targets.map((target) => { + // The folded elements that lead to `target`, found by walking back over the transitions met; only folded elements are walked through, as the routes only pass through those. + const leading = new Set([target]); + const back: I["element"][] = [target]; + for (let at = back.pop(); at !== undefined; at = back.pop()) { + for (const transition of incoming.get(at) ?? []) { + if (state.has(transition.source) && !leading.has(transition.source)) { + leading.add(transition.source); + back.push(transition.source); + } + } + } + const transitions = met.filter((transition) => + leading.has(transition.target), + ); + const through = entered.filter((element) => leading.has(element)); + const leaving = new Map(); + for (const transition of transitions) { + leaving.set(transition.source, (leaving.get(transition.source) ?? 0) + 1); + } + + return { + transitions, + through, + target, + merged: + transitions.length !== through.length + 1 || + through.some( + (element) => element !== target && leaving.get(element) !== 1, + ), + }; + }); }; 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..d5d01c9 --- /dev/null +++ b/packages/core/graph/projection/src/chains.unit.test.ts @@ -0,0 +1,205 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { chainsFrom, outgoingMap } from "./chains.ts"; +import { + diffFlowchart, + handoffs, + toFlowchart, + toStateMachine, +} from "./index.ts"; + +/** 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("visits each folded element at most once per first transition", () => { + const outgoing = outgoingMap(layered); + const messages = new Set( + layered.elements.flatMap((element) => + element.kind === "message" ? [element.id] : [], + ), + ); + for (const first of layered.transitions.filter((transition) => + sources.includes(transition.source), + )) { + const visits = new Map(); + const chains = chainsFrom(first, outgoing, (element) => { + visits.set(element, (visits.get(element) ?? 0) + 1); + + return messages.has(element); + }); + + expect([...visits.values()].every((count) => count === 1)).toBe(true); + expect(chains.map((chain) => chain.target)).toEqual(ends); + expect(chains.every((chain) => chain.merged)).toBe(true); + } + }); + + 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"], + }, + ]); + }); +}); diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index bfdec3c..e0e0121 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -13,11 +13,11 @@ export interface FlowNode { export interface FlowEdge { readonly from: I["element"]; readonly to: I["element"]; - /** The guards along the edge joined with "and", or, when there are none, the labels of the messages it carries. */ + /** 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, in order. An edit to the edge maps back to the model through these. */ + /** 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, in order, when it stands for a chain. */ + /** 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"][]; } @@ -80,7 +80,7 @@ export const flowchartLoss = ( ), ]; -/** The edge a chain of transitions draws: guards joined, or the carried messages' labels when no transition on it is guarded. */ +/** The edge a chain of transitions draws. On a single route its label is the route's guards joined with "and", or, when none is guarded, the carried messages' labels. Where several routes are merged the guards belong to different routes and cannot be joined, so the label is the carried messages' labels only. */ const edgeOf = ( from: I["element"], chain: Chain, @@ -95,7 +95,7 @@ const edgeOf = ( return label === undefined ? [] : [label]; }); const label = - guards.length > 0 + guards.length > 0 && !chain.merged ? guards.join(" and ") : carried.length > 0 ? carried.join(", ") @@ -111,7 +111,7 @@ const edgeOf = ( }; /** - * 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`). 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. + * 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 drawn element it leads to, however many routes through message elements connect them. 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. */ diff --git a/packages/core/graph/projection/src/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts index fbb75fb..c140769 100644 --- a/packages/core/graph/projection/src/inverse-flowchart.ts +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -1,5 +1,6 @@ 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 { toFlowchart, type FlowEdge, type FlowNode } from "./flowchart.ts"; import type { Swimlane } from "./swimlane.ts"; @@ -124,13 +125,20 @@ const diffFlow = ( const transitions = new Map( model.transitions.map((transition) => [transition.id, transition]), ); + /** The transitions of an edge that arrive at its drawn end: the edge's one transition, or the last step of each route through the messages it carries. */ + const arriving = (edge: FlowEdge): TransitionOf[] => + edge.transitions.flatMap((id) => { + const transition = transitions.get(id); + + return transition?.target === edge.to ? [transition] : []; + }); for (const edge of original.edges) { if (!editedKeys.has(edgeKey(edge))) { - // Removing an edge that carries a message removes every step of it, which leaves the message element unconnected: a choice, so inferred. - for (const transition of edge.transitions) { + // An edge that carries messages shares its earlier steps with every other edge through the same messages, so only the steps arriving at its end are removed, and which of them is a choice: inferred. + for (const transition of arriving(edge)) { transitionChanges.push({ kind: "removed", - transition, + transition: transition.id, inferred: edge.transitions.length > 1, }); } @@ -162,10 +170,8 @@ const diffFlow = ( }); continue; } - const last = edge.transitions.at(-1); const head = first === undefined ? undefined : transitions.get(first); - const tail = last === undefined ? undefined : transitions.get(last); - if (head === undefined || tail === undefined) { + if (head === undefined) { continue; } const chain = edge.transitions.length > 1; @@ -188,13 +194,16 @@ const diffFlow = ( }); } if (before.to !== edge.to) { - transitionChanges.push({ - kind: "rewired", - transition: tail.id, - from: { source: tail.source, target: tail.target }, - to: { source: tail.source, target: edge.to }, - inferred: false, - }); + // The arriving steps are shared with every other edge through the same messages, so moving them is a choice: inferred. + for (const tail of arriving(before)) { + transitionChanges.push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edge.to }, + inferred: true, + }); + } } } if (before.label !== edge.label) { @@ -220,7 +229,7 @@ const diffFlow = ( /** * 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 the transitions it stands for: a new edge must carry one new transition id and is an added transition; a projected edge missing from the edit removes its transitions; moved ends rewire the transition (for an edge that carries a message, the first step's source or the last step's target); a changed label changes the guard. + * 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 the transitions it stands for: 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 arriving at its end 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. diff --git a/packages/core/graph/projection/src/inverse-state-machine.ts b/packages/core/graph/projection/src/inverse-state-machine.ts index 11cf759..09d07f9 100644 --- a/packages/core/graph/projection/src/inverse-state-machine.ts +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -1,5 +1,6 @@ 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 { toStateMachine, @@ -30,7 +31,7 @@ const addedState = ( * * 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 the model transitions it stands for. 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 the last step and is marked `inferred`. A new transition must carry one new transition id and is added with its event, guard and effect. + * A drawn transition is matched by the model transitions it stands for. 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 that arrives at its target (one per route) 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. @@ -102,14 +103,24 @@ export const diffStateMachine = ( ); const editedKeys = new Set(edited.transitions.map(transitionKey)); const transitionChanges: TransitionChange[] = []; + /** The model transitions of a drawn transition that arrive at its target state: its one transition, or the last step of each route through the elements folded into it. */ + const arriving = ( + drawnTransition: StateMachineTransition, + ): TransitionOf[] => + drawnTransition.transitions.flatMap((id) => { + const transition = transitions.get(id); + + return transition?.target === drawnTransition.target ? [transition] : []; + }); for (const drawnTransition of original.transitions) { - const last = drawnTransition.transitions.at(-1); - if (last !== undefined && !editedKeys.has(transitionKey(drawnTransition))) { - transitionChanges.push({ - kind: "removed", - transition: last, - inferred: drawnTransition.transitions.length > 1, - }); + if (!editedKeys.has(transitionKey(drawnTransition))) { + for (const tail of arriving(drawnTransition)) { + transitionChanges.push({ + kind: "removed", + transition: tail.id, + inferred: drawnTransition.transitions.length > 1, + }); + } } } for (const edit of edited.transitions) { @@ -141,11 +152,10 @@ export const diffStateMachine = ( continue; } const head = first === undefined ? undefined : transitions.get(first); - const lastId = edit.transitions.at(-1); - const tail = lastId === undefined ? undefined : transitions.get(lastId); - if (head === undefined || tail === undefined) { + if (head === undefined) { continue; } + const tails = arriving(before); const chain = edit.transitions.length > 1; if ( !chain && @@ -169,16 +179,18 @@ export const diffStateMachine = ( }); } if (before.target !== edit.target) { - transitionChanges.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 tails) { + transitionChanges.push({ + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edit.target }, + inferred: true, + }); + } } } - if (before.event !== edit.event) { + for (const tail of before.event === edit.event ? [] : tails) { transitionChanges.push({ kind: "changed", field: "trigger", @@ -188,7 +200,7 @@ export const diffStateMachine = ( inferred: chain, }); } - if (before.guard !== edit.guard) { + for (const tail of before.guard === edit.guard ? [] : tails) { transitionChanges.push({ kind: "changed", field: "guard", @@ -205,14 +217,16 @@ export const diffStateMachine = ( `The effect of ${transitionKey(edit)} is the action of "${target.id}"; change that activity's action instead`, ); } - transitionChanges.push({ - kind: "changed", - field: "effect", - transition: tail.id, - from: tail.effect, - to: edit.effect, - inferred: chain, - }); + for (const tail of tails) { + transitionChanges.push({ + kind: "changed", + field: "effect", + transition: tail.id, + from: tail.effect, + to: edit.effect, + inferred: chain, + }); + } } } diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index b5ddb20..7197ae7 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -15,9 +15,9 @@ export interface StateMachineTransition { readonly event?: I["event"]; readonly guard?: string; readonly effect?: I["action"]; - /** The model transitions this transition stands for, in order. An edit to it maps back to the model through these. */ + /** 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, in order: decisions, activities that do not affect the subject, and `message` elements. */ + /** 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"][]; } @@ -60,7 +60,7 @@ const stateOf = ( * * 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. + * 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. * @@ -105,7 +105,7 @@ export const toStateMachine = ( return element !== undefined && isTransparent(element); }; - /** The transition a chain draws. The event is the last trigger along it, counting a folded message's own event at its place in the chain; guards are joined in order; the effect is the entry action of the target activity, or else the last effect along the chain. */ + /** 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. */ const transitionOf = ( source: I["element"], chain: Chain, @@ -113,7 +113,7 @@ export const toStateMachine = ( const triggers: I["event"][] = []; const guards: string[] = []; const effects: I["action"][] = []; - for (const [index, transition] of chain.transitions.entries()) { + for (const transition of chain.merged ? [] : chain.transitions) { if (transition.trigger !== undefined) { triggers.push(transition.trigger); } @@ -123,11 +123,13 @@ export const toStateMachine = ( if (transition.effect !== undefined) { effects.push(transition.effect); } - // `through[index]` is the folded element this transition arrives at; the last transition arrives at the target instead. - const passed = chain.through[index]; - const folded = passed === undefined ? undefined : elements.get(passed); - if (folded?.kind === "message" && folded.event !== undefined) { - triggers.push(folded.event); + const arrived = elements.get(transition.target); + if ( + transition.target !== chain.target && + arrived?.kind === "message" && + arrived.event !== undefined + ) { + triggers.push(arrived.event); } } const target = elements.get(chain.target); From fcf3b740e1d7fa696bb30623deb04db2af491362 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 17:45:23 +0100 Subject: [PATCH 06/33] feat(dag): export stronglyConnectedComponents The iterative Tarjan pass findCycles is built on is now public, with its contract: every node in exactly one component, each node and edge visited once, and a component listed before every component that needs it. --- packages/core/graph/dag/README.md | 19 +++++----- packages/core/graph/dag/src/cycles.ts | 8 +++- .../core/graph/dag/src/cycles.unit.test.ts | 37 ++++++++++++++++++- packages/core/graph/dag/src/index.ts | 2 +- 4 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/core/graph/dag/README.md b/packages/core/graph/dag/README.md index d351e1c..3d1d465 100644 --- a/packages/core/graph/dag/README.md +++ b/packages/core/graph/dag/README.md @@ -40,15 +40,16 @@ 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. | +| `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/index.ts b/packages/core/graph/dag/src/index.ts index f4f59e4..4092544 100644 --- a/packages/core/graph/dag/src/index.ts +++ b/packages/core/graph/dag/src/index.ts @@ -7,6 +7,6 @@ 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 { transitiveReduction } from "./reduction.ts"; export { criticalPath, type CriticalPath } from "./critical-path.ts"; From 443b7bb443289c77600c35d19df38fce866f4dd1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 17:45:30 +0100 Subject: [PATCH 07/33] fix(projection): derive loop fragments from strongly connected components A decision's transition is a loop transition exactly when its target is in the decision's strongly connected component, so one Tarjan pass over the model decides every loop transition and every loop body at once, instead of a reachability walk per transition and per body element. A decision now draws one loop, over its whole component, guarded by its loop transitions' guards joined with "or". Decisions and forks the scenario does not pass derive nothing, and fragment placement tests membership through sets of positions rather than scanning entries. --- packages/core/graph/projection/README.md | 16 ++-- packages/core/graph/projection/package.json | 1 + .../graph/projection/src/derived-fragments.ts | 68 +++++++++++---- .../src/derived-fragments.unit.test.ts | 82 +++++++++++++++++++ .../core/graph/projection/src/sequence.ts | 60 ++++++++------ .../core/graph/projection/tsconfig.build.json | 3 + pnpm-lock.yaml | 3 + 7 files changed, 183 insertions(+), 50 deletions(-) create mode 100644 packages/core/graph/projection/src/derived-fragments.unit.test.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 3b7e3c2..b2a4cd9 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -49,16 +49,16 @@ A step between two elements with actors is a message, labelled by its trigger's `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, restricted to the scenario path: +Fragments are derived from the model's structure at the decisions and forks the scenario passes, restricted to the scenario path: -| Structure | Fragment | Operands | -| --------------------------------------------------------------------------- | -------- | --------------------------------------- | -| A decision's transition whose target can reach the decision again | `loop` | One, guarded by that transition's guard | -| A decision's other (forward) branches, when there are at least two | `alt` | One per branch, guarded by the branch | -| Exactly two forward branches, one of them going straight to where they meet | `opt` | The branch that does something | -| A fork pseudo-state's branches | `par` | One per branch | +| Structure | Fragment | Operands | +| --------------------------------------------------------------------------- | -------- | ---------------------------------------------------------- | +| A decision's transitions whose target can reach the decision again | `loop` | One, guarded by those transitions' guards joined with "or" | +| A decision's other (forward) branches, when there are at least two | `alt` | One per branch, guarded by the branch | +| Exactly two forward branches, one of them going straight to where they meet | `opt` | The branch that does something | +| A fork pseudo-state's branches | `par` | One per branch | -A loop transition is the same one `graph-domain` treats as a legal cycle. A loop's body is every element on a cycle through it; a branch's body is what it reaches, over forward transitions, that not every branch reaches (what every branch reaches lies after they meet). 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. +A loop transition is the same one `graph-domain` treats as a legal cycle: its target is in the decision's strongly connected component, which one pass of Tarjan's algorithm finds for every element. A decision draws one loop however many ways round it has, and its body is that whole component, every element on a cycle through the decision; a branch's body is what it reaches, over forward transitions, that not every branch reaches (what every branch reaches lies after they meet). 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. 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 an authored one left out is reported as a `fragments` loss. 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/derived-fragments.ts b/packages/core/graph/projection/src/derived-fragments.ts index ab050a7..024f284 100644 --- a/packages/core/graph/projection/src/derived-fragments.ts +++ b/packages/core/graph/projection/src/derived-fragments.ts @@ -3,6 +3,10 @@ import type { IdSpace, Model, } from "@exadev/process-graph-schema"; +import { + buildDag, + stronglyConnectedComponents, +} from "@exadev/process-graph-dag"; import { outgoingMap, type TransitionOf } from "./chains.ts"; /** One operand of a fragment the projection derives from the model's structure: the guard of the branch it follows and the elements of that branch on the scenario path. */ @@ -22,15 +26,17 @@ export interface DerivedFragment { /** 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; -/** Elements reachable from `start` by following transitions, `start` included. The walk uses an explicit stack. */ +/** Elements reachable from `start` by following transitions other than those in `skip`, `start` included. The walk uses an explicit stack, and `observe` is told of each element as it is expanded. */ const reachable = ( start: I["element"], outgoing: ReadonlyMap[]>, skip: ReadonlySet, + observe: ((element: I["element"]) => void) | undefined, ): ReadonlySet => { const seen = new Set([start]); const stack: I["element"][] = [start]; for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + observe?.(next); for (const transition of outgoing.get(next) ?? []) { if (!skip.has(transition.id) && !seen.has(transition.target)) { seen.add(transition.target); @@ -43,36 +49,50 @@ const reachable = ( }; /** - * The fragments the model's structure implies, restricted to the elements on the scenario path. + * The fragments the model's structure implies at the decisions and forks the scenario passes, restricted to the elements on the scenario path. * - * A transition leaving a decision is a loop transition when its target can reach the decision again, the same rule `graph-domain` uses for a legal cycle. Each loop transition yields a `loop` whose body is every element on a cycle through it, guarded by the loop transition's guard. The decision's other (forward) branches, when there are at least two, yield an `alt` with one operand per branch; when there are exactly two and one of them goes straight to where the branches meet, the fragment is an `opt` around the other. A fork pseudo-state's branches yield a `par`. A branch's body is the elements reachable from it, over forward transitions only, that are not reachable from every branch: those that every branch reaches lie after the branches meet. + * A transition leaving a decision is a loop transition when its target can reach the decision again, the same rule `graph-domain` uses for a legal cycle. Since the decision reaches that target through the transition, this holds exactly when the target is in the decision's strongly connected component, which is found for every element at once by one pass of Tarjan's algorithm. A decision with loop transitions yields one `loop` whose body is its whole component, which is every element on a cycle through it, guarded by its loop transitions' guards joined with "or", since taking any of them goes round again. The decision's other (forward) branches, when there are at least two, yield an `alt` with one operand per branch; when there are exactly two and one of them goes straight to where the branches meet, the fragment is an `opt` around the other. A fork pseudo-state's branches yield a `par`. A branch's body is the elements reachable from it, over forward transitions only, that are not reachable from every branch: those that every branch reaches lie after the branches meet. + * + * The work is one pass over every element and transition for the components, then, for each decision or fork the scenario passes, one walk over forward transitions per forward branch. Loops add no walks, and decisions and forks off the path add none. * @param model - The model the scenario runs through. * @param onPath - The elements the scenario passes, to which every body is restricted. - * @returns Derived fragments in model order of the decision or fork they come from, each decision's loops before its alternatives. + * @param observe - Told of each element a branch walk expands, so a caller can measure the work done. + * @returns Derived fragments in model order of the decision or fork they come from, each decision's loop before its alternatives. */ export const deriveFragments = ( model: Model, onPath: ReadonlySet, + observe?: (element: I["element"]) => void, ): readonly DerivedFragment[] => { const outgoing = outgoingMap(model); - const none = new Set(); - const reaches = (from: I["element"], to: I["element"]): boolean => - reachable(from, outgoing, none).has(to); + const graph = buildDag( + model.elements.map((element) => element.id), + (id) => (outgoing.get(id) ?? []).map((transition) => transition.target), + ); + const component = new Map(); + for (const members of stronglyConnectedComponents(graph)) { + for (const member of members) { + component.set(member, members); + } + } const loops = new Set(); for (const element of model.elements) { if (element.kind !== "decision") { continue; } + // Elements share a component exactly when they map to the same members list, so this is a constant-time test per transition. + const own = component.get(element.id); for (const transition of outgoing.get(element.id) ?? []) { - if (reaches(transition.target, element.id)) { + if (own !== undefined && component.get(transition.target) === own) { loops.add(transition.id); } } } - const onPathOnly = (elements: readonly I["element"][]): I["element"][] => - elements.filter((id) => onPath.has(id)); + const onPathOnly = ( + elements: Readonly>, + ): I["element"][] => [...elements].filter((id) => onPath.has(id)); /** One operand per branch, with the branches' shared tail removed, and whether each branch goes straight to where the branches meet. */ const branchesOf = ( @@ -83,7 +103,7 @@ export const deriveFragments = ( }[] => { const reach = branches.map((branch) => ({ branch, - elements: reachable(branch.target, outgoing, loops), + elements: reachable(branch.target, outgoing, loops, observe), })); // An element every branch reaches lies after the branches meet; it is enough to test those the first branch reaches. const [head] = reach; @@ -106,18 +126,30 @@ export const deriveFragments = ( const derived: DerivedFragment[] = []; for (const element of model.elements) { + if (!onPath.has(element.id)) { + continue; + } const leaving = outgoing.get(element.id) ?? []; if (element.kind === "decision") { - for (const loop of leaving.filter((transition) => - loops.has(transition.id), - )) { - const body = [...reachable(loop.target, outgoing, none)].filter( - (id) => id === element.id || reaches(id, element.id), - ); + const back = leaving.filter((transition) => loops.has(transition.id)); + const own = component.get(element.id); + if (back.length > 0 && own !== undefined) { + const guards = [ + ...new Set( + back.flatMap((transition) => + transition.guard === undefined ? [] : [transition.guard], + ), + ), + ]; derived.push({ element: element.id, operator: "loop", - operands: [{ guard: loop.guard, body: onPathOnly(body) }], + operands: [ + { + guard: guards.length > 0 ? guards.join(" or ") : undefined, + body: onPathOnly(own), + }, + ], }); } const forward = leaving.filter((transition) => !loops.has(transition.id)); diff --git a/packages/core/graph/projection/src/derived-fragments.unit.test.ts b/packages/core/graph/projection/src/derived-fragments.unit.test.ts new file mode 100644 index 0000000..d35f5d1 --- /dev/null +++ b/packages/core/graph/projection/src/derived-fragments.unit.test.ts @@ -0,0 +1,82 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { deriveFragments } from "./derived-fragments.ts"; + +/** Enough loop branches that a walk per branch, or per loop element, costs far more than one pass over the graph. */ +const loopBranches = 300; + +const branches = Array.from( + { length: loopBranches }, + (_, index) => `l-${String(index)}`, +); + +/** One decision with many ways round and back to it, and two ways out. */ +const retrying: Model = { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A" }, + { kind: "decision", id: "d", label: "D" }, + ...branches.map((id) => ({ kind: "activity" as const, id, label: id })), + { kind: "activity", id: "x", label: "X" }, + { kind: "activity", id: "y", label: "Y" }, + ], + 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" }, + ], + fragments: [], + projections: [], +}; + +const everything = new Set(retrying.elements.map((element) => element.id)); + +describe("deriveFragments on a decision with many loop branches", () => { + it("derives one loop over the decision's whole cycle and one alt for its ways out", () => { + const [loop, alt, ...rest] = deriveFragments(retrying, everything); + + expect(rest).toEqual([]); + expect(loop?.element).toBe("d"); + expect(loop?.operator).toBe("loop"); + expect(loop?.operands.map((operand) => operand.guard)).toEqual(["again"]); + expect(new Set(loop?.operands[0]?.body)).toEqual( + new Set(["d", ...branches]), + ); + expect(alt).toEqual({ + element: "d", + operator: "alt", + operands: [ + { guard: "done", body: ["x"] }, + { guard: "gave up", body: ["y"] }, + ], + }); + }); + + it("expands no more elements than the graph has elements and transitions", () => { + let expanded = 0; + deriveFragments(retrying, everything, () => { + expanded += 1; + }); + + expect(expanded).toBeLessThanOrEqual( + retrying.elements.length + retrying.transitions.length, + ); + }); + + it("does no walks for a scenario that passes no decision or fork", () => { + let expanded = 0; + const derived = deriveFragments(retrying, new Set(), () => { + expanded += 1; + }); + + expect(derived).toEqual([]); + expect(expanded).toBe(0); + }); +}); diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index 5e6f33b..e9f54c2 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -68,6 +68,8 @@ interface Entry { interface TakenOperand { readonly guard: string | undefined; readonly entries: readonly Entry[]; + /** The scenario positions of `entries`, for constant-time membership. */ + readonly indices: ReadonlySet; } /** A place fragments are drawn into: the top level, or one operand of another fragment. */ @@ -128,9 +130,9 @@ const wrapFragments = ( ) { continue; } + const body = new Set(operand.body); const inside = entries.filter( - (entry) => - operand.body.includes(entry.target) && !claimed.has(entry.index), + (entry) => body.has(entry.target) && !claimed.has(entry.index), ); if (inside.length === 0) { continue; @@ -138,10 +140,19 @@ const wrapFragments = ( for (const entry of inside) { claimed.add(entry.index); } - operands.push({ guard: operand.guard, entries: inside }); + operands.push({ + guard: operand.guard, + entries: inside, + indices: new Set(inside.map((entry) => entry.index)), + }); + } + // A fold rather than a spread into Math.min and Math.max, which would pass every position as an argument. + let first = Number.POSITIVE_INFINITY; + let last = Number.NEGATIVE_INFINITY; + for (const index of claimed) { + first = Math.min(first, index); + last = Math.max(last, index); } - const first = Math.min(...claimed); - const last = Math.max(...claimed); const duplicate = fragment.authored === undefined && candidates.some( @@ -166,30 +177,29 @@ const wrapFragments = ( // 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), + // Descends into the operand of the first overlapping fragment that holds all of `node`'s messages; an overlap no operand holds is not drawn. + const place = (node: Placed): void => { + const positions = [...node.indices]; + for ( + let container: Container | undefined = top; + container !== undefined; + ) { + const sibling: Placed | undefined = container.children.find((child) => + positions.some((index) => child.indices.has(index)), ); - if (shared.length === 0) { - continue; + if (sibling === undefined) { + container.children.push(node); + + return; } - const slot = sibling.operands.findIndex((operand) => - [...node.indices].every((index) => - operand.entries.some((entry) => entry.index === index), - ), + const slot: number = sibling.operands.findIndex((operand) => + positions.every((index) => operand.indices.has(index)), ); - const inner = slot === -1 ? undefined : sibling.nested[slot]; - if (inner !== undefined) { - place(node, inner); - } - - return; + container = slot === -1 ? undefined : sibling.nested[slot]; } - container.children.push(node); }; for (const node of bySize) { - place(node, top); + place(node); } const drawn = new Set(); @@ -263,6 +273,7 @@ export const toSequence = ( const skipped: I["transition"][] = []; const lifelines: I["actor"][] = []; + const onLifeline = new Set(); const messages: SequenceMessage[] = []; const entries: Entry[] = []; const shownMessages = new Set(); @@ -271,7 +282,8 @@ export const toSequence = ( let arrivedAt: I["element"] | undefined; const draw = (message: SequenceMessage, target: I["element"]): void => { for (const participant of [message.from, message.to]) { - if (!lifelines.includes(participant)) { + if (!onLifeline.has(participant)) { + onLifeline.add(participant); lifelines.push(participant); } } 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 From f272eda28920f9cc2dfbfbc41b09aac1b71fe4b7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 17:45:38 +0100 Subject: [PATCH 08/33] fix(projection): match repeated sequence steps without copying diffSequence grouped the projected messages by transition by copying each group on every repeat and took them off with shift, so a scenario repeating a step did work quadratic in its length. Occurrences appends to each group and reads it through a position, touching each message a constant number of times; where a new step leaves from is kept per actor rather than found by scanning back. --- .../graph/projection/src/inverse-sequence.ts | 37 +++++-------- .../core/graph/projection/src/occurrences.ts | 55 +++++++++++++++++++ .../projection/src/occurrences.unit.test.ts | 37 +++++++++++++ 3 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 packages/core/graph/projection/src/occurrences.ts create mode 100644 packages/core/graph/projection/src/occurrences.unit.test.ts diff --git a/packages/core/graph/projection/src/inverse-sequence.ts b/packages/core/graph/projection/src/inverse-sequence.ts index b39b146..6a3fe25 100644 --- a/packages/core/graph/projection/src/inverse-sequence.ts +++ b/packages/core/graph/projection/src/inverse-sequence.ts @@ -1,5 +1,6 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; +import { Occurrences } from "./occurrences.ts"; import type { ElementChange, EventChange, @@ -58,27 +59,22 @@ export const diffSequence = ( ); const actors = actorMap(model); - const pending = new Map[]>(); - for (const message of original.messages) { - pending.set(message.transition, [ - ...(pending.get(message.transition) ?? []), - message, - ]); - } + const pending = new Occurrences( + original.messages, + (message: SequenceMessage) => message.transition, + ); const elementChanges: ElementChange[] = []; const transitionChanges: TransitionChange[] = []; const eventChanges: EventChange[] = []; - /** Where each edited message arrives, by position, for placing a new step after it. */ - const arrivals: { readonly actor: I["actor"]; readonly at: I["element"] }[] = - []; + /** 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 queue = pending.get(message.transition); - const before = queue?.shift(); + const before = pending.next(message.transition); const transition = transitions.get(message.transition); if (before === undefined || transition === undefined) { if (transition !== undefined) { @@ -92,9 +88,7 @@ export const diffSequence = ( `New message "${message.transition}" must name the element it arrives at in \`element\``, ); } - const source = arrivals.findLast( - ({ actor }) => actor === message.from, - )?.at; + 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`, @@ -117,7 +111,7 @@ export const diffSequence = ( transition: { id: message.transition, source, target }, inferred: true, }); - arrivals.push({ actor: message.to, at: target }); + arrivals.set(message.to, target); inserted = { from: source, element: target }; continue; } @@ -137,10 +131,7 @@ export const diffSequence = ( }); } inserted = undefined; - arrivals.push({ - actor: message.to, - at: before.element ?? transition.target, - }); + arrivals.set(message.to, before.element ?? transition.target); if (before.label !== message.label) { const trigger = @@ -211,10 +202,8 @@ export const diffSequence = ( } } - for (const [transition, left] of pending) { - if (left.length > 0) { - transitionChanges.push({ kind: "removed", transition, inferred: true }); - } + for (const transition of pending.unread()) { + transitionChanges.push({ kind: "removed", transition, inferred: true }); } return { 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"]); + }); +}); From 4ff9b403a5573272f6c2aaa9d381996ae24d2e99 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:25:12 +0100 Subject: [PATCH 09/33] fix(projection): index edits and changes in one pass before using them diffFlowchart inferred each added terminator's and bar's role by scanning every edited edge, and applyModelDiff filtered every change list once per retained element, transition and event. Edge degrees are now counted in one pass over the edited edges, and changes are grouped by the identifier they are about once, so both read each input item a fixed number of times whatever the size of the edit. --- .../projection/src/bounded-work.unit.test.ts | 102 ++++++++++++++++++ packages/core/graph/projection/src/diff.ts | 45 ++++++-- .../graph/projection/src/inverse-flowchart.ts | 30 +++++- 3 files changed, 163 insertions(+), 14 deletions(-) create mode 100644 packages/core/graph/projection/src/bounded-work.unit.test.ts 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..4c026d3 --- /dev/null +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -0,0 +1,102 @@ +import type { Model, OpenIds } from "@exadev/process-graph-schema"; +import { describe, expect, it } from "vitest"; +import { + applyModelDiff, + diffFlowchart, + 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; + +/** 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 }; +}; + +const ids = (count: number, prefix: string): string[] => + Array.from({ length: count }, (_, index) => `${prefix}${String(index)}`); + +const empty: Model = { + participants: [], + events: [], + actions: [], + artefacts: [], + elements: [], + transitions: [], + fragments: [], + projections: [], +}; + +/** 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)); + }); +}); diff --git a/packages/core/graph/projection/src/diff.ts b/packages/core/graph/projection/src/diff.ts index 87d1b8a..c317ea4 100644 --- a/packages/core/graph/projection/src/diff.ts +++ b/packages/core/graph/projection/src/diff.ts @@ -234,6 +234,25 @@ const applyTransitionChange = ( : { ...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, @@ -303,13 +322,19 @@ export const applyModelDiff = ( ), ); + // 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) => - diff.elements - .filter((change) => subjectOf(change) === element.id) - .reduce(applyElementChange, element), + (elementChanges.get(element.id) ?? []).reduce( + applyElementChange, + element, + ), ), ...diff.elements.flatMap((change) => change.kind === "added" ? [change.element] : [], @@ -319,18 +344,20 @@ export const applyModelDiff = ( ...model.transitions .filter((transition) => !removedTransitions.has(transition.id)) .map((transition) => - diff.transitions - .filter((change) => transitionSubjectOf(change) === transition.id) - .reduce(applyTransitionChange, transition), + (transitionChanges.get(transition.id) ?? []).reduce( + applyTransitionChange, + transition, + ), ), ...diff.transitions.flatMap((change) => change.kind === "added" ? [change.transition] : [], ), ]; const events = model.events.map((event) => - diff.events - .filter((change) => change.event === event.id) - .reduce((renamed, change) => ({ ...renamed, name: change.to }), event), + (eventChanges.get(event.id) ?? []).reduce( + (renamed, change) => ({ ...renamed, name: change.to }), + event, + ), ); const fragments = model.fragments.map((fragment) => ({ ...fragment, diff --git a/packages/core/graph/projection/src/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts index c140769..3fc4c47 100644 --- a/packages/core/graph/projection/src/inverse-flowchart.ts +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -9,10 +9,29 @@ import type { Swimlane } from "./swimlane.ts"; const edgeKey = (edge: FlowEdge): string => JSON.stringify(edge.transitions); +/** 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, - edges: readonly FlowEdge[], + degrees: Degrees, actor: I["actor"] | undefined, ): ElementChange => { const owned = actor === undefined ? {} : { actor }; @@ -36,13 +55,13 @@ const addedElement = ( element: { kind: "pseudo", id: node.id, - role: edges.some((edge) => edge.to === node.id) ? "final" : "initial", + role: (degrees.in.get(node.id) ?? 0) > 0 ? "final" : "initial", }, inferred: true, }; } - const ins = edges.filter((edge) => edge.to === node.id).length; - const outs = edges.filter((edge) => edge.from === node.id).length; + const ins = degrees.in.get(node.id) ?? 0; + const outs = degrees.out.get(node.id) ?? 0; return { kind: "added", @@ -74,6 +93,7 @@ const diffFlow = ( 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) { @@ -93,7 +113,7 @@ const diffFlow = ( `Node "${node.id}" is a model element the flowchart does not draw`, ); } - elementChanges.push(addedElement(node, edited.edges, actorOf(node.id))); + elementChanges.push(addedElement(node, degrees, actorOf(node.id))); continue; } if (before.shape !== node.shape) { From c722e00000896c0a5efdeb58adf51e841fd328d7 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:28:43 +0100 Subject: [PATCH 10/33] feat(dag): find immediate dominators immediateDominators maps every node reachable from a root along needs edges to its immediate dominator, by the Lengauer-Tarjan algorithm with path compression and explicit stacks. Read with needs pointing back to earlier steps and a virtual end as the root, it gives post-dominators: where every way forward from a step meets again. --- packages/core/graph/dag/README.md | 1 + packages/core/graph/dag/src/dominators.ts | 114 +++++++++++ .../graph/dag/src/dominators.unit.test.ts | 179 ++++++++++++++++++ packages/core/graph/dag/src/index.ts | 1 + 4 files changed, 295 insertions(+) create mode 100644 packages/core/graph/dag/src/dominators.ts create mode 100644 packages/core/graph/dag/src/dominators.unit.test.ts diff --git a/packages/core/graph/dag/README.md b/packages/core/graph/dag/README.md index 3d1d465..73dc1c3 100644 --- a/packages/core/graph/dag/README.md +++ b/packages/core/graph/dag/README.md @@ -48,6 +48,7 @@ Nothing is absorbed silently: | `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. | 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 4092544..69535f3 100644 --- a/packages/core/graph/dag/src/index.ts +++ b/packages/core/graph/dag/src/index.ts @@ -8,5 +8,6 @@ export { CycleError, UnknownNodeError } from "./errors.ts"; export { topologicalLayers, type TopologicalLayers } from "./layers.ts"; export { ancestorsOf, descendantsOf } from "./reachability.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"; From 0cd428bd197a680c40c8cc3bd8555312d841238e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:32:25 +0100 Subject: [PATCH 11/33] fix(projection): derive sequence fragments along the path in linear passes Derived fragments walked and kept the whole reachable set of every forward branch to find where the branches meet, so a decision whose branches share one tail did work quadratic in the model. Where branches meet is now the decision's or fork's immediate post-dominator over forward transitions, found for every element in one dominator pass, and the scenario is read once: an alt or opt holds the messages from the branch taken until the path reaches that point, a par follows each branch taken from its fork, and a loop holds the messages inside its decision's component. Authored fragments are matched to messages through an index of their bodies, and placement marks the span each placed fragment covers, so no step scans the list of candidates or messages. --- packages/core/graph/projection/README.md | 18 +- .../projection/src/bounded-work.unit.test.ts | 159 +++++ .../graph/projection/src/derived-fragments.ts | 186 ------ .../src/derived-fragments.unit.test.ts | 82 --- .../core/graph/projection/src/fragments.ts | 608 ++++++++++++++++++ .../core/graph/projection/src/sequence.ts | 210 +----- 6 files changed, 780 insertions(+), 483 deletions(-) delete mode 100644 packages/core/graph/projection/src/derived-fragments.ts delete mode 100644 packages/core/graph/projection/src/derived-fragments.unit.test.ts create mode 100644 packages/core/graph/projection/src/fragments.ts diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index b2a4cd9..347c896 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -49,18 +49,18 @@ A step between two elements with actors is a message, labelled by its trigger's `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 at the decisions and forks the scenario passes, restricted to the scenario path: +Fragments are derived from the model's structure as the scenario passes its decisions and forks: -| Structure | Fragment | Operands | -| --------------------------------------------------------------------------- | -------- | ---------------------------------------------------------- | -| A decision's transitions whose target can reach the decision again | `loop` | One, guarded by those transitions' guards joined with "or" | -| A decision's other (forward) branches, when there are at least two | `alt` | One per branch, guarded by the branch | -| Exactly two forward branches, one of them going straight to where they meet | `opt` | The branch that does something | -| A fork pseudo-state's branches | `par` | One per branch | +| 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 loop transition is the same one `graph-domain` treats as a legal cycle: its target is in the decision's strongly connected component, which one pass of Tarjan's algorithm finds for every element. A decision draws one loop however many ways round it has, and its body is that whole component, every element on a cycle through the decision; a branch's body is what it reaches, over forward transitions, that not every branch reaches (what every branch reaches lies after they meet). 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. +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. -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 an authored one left out is reported as a `fragments` loss. +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. 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. 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. Placing a fragment marks the span it covers, so the work of nesting is the number of messages times the depth fragments nest to. ## Loss reports diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 4c026d3..3341888 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { applyModelDiff, diffFlowchart, + toSequence, type FlowEdge, type FlowNode, type ModelDiff, @@ -12,6 +13,8 @@ import { * 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 = ( @@ -100,3 +103,159 @@ describe("work grows in proportion to the input", () => { 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")] }], + }, + ]); + }); +}); diff --git a/packages/core/graph/projection/src/derived-fragments.ts b/packages/core/graph/projection/src/derived-fragments.ts deleted file mode 100644 index 024f284..0000000 --- a/packages/core/graph/projection/src/derived-fragments.ts +++ /dev/null @@ -1,186 +0,0 @@ -import type { - FragmentOperator, - IdSpace, - Model, -} from "@exadev/process-graph-schema"; -import { - buildDag, - stronglyConnectedComponents, -} from "@exadev/process-graph-dag"; -import { outgoingMap, type TransitionOf } from "./chains.ts"; - -/** One operand of a fragment the projection derives from the model's structure: the guard of the branch it follows and the elements of that branch on the scenario path. */ -interface DerivedOperand { - readonly guard: string | undefined; - readonly body: readonly I["element"][]; -} - -/** A fragment read off the model's structure rather than authored: an `alt` or `opt` from a decision's branches, a `loop` from a decision's way back to earlier work, a `par` from a fork's branches. */ -export interface DerivedFragment { - /** The decision or fork the fragment comes from. */ - readonly element: I["element"]; - readonly operator: FragmentOperator; - readonly operands: readonly DerivedOperand[]; -} - -/** 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; - -/** Elements reachable from `start` by following transitions other than those in `skip`, `start` included. The walk uses an explicit stack, and `observe` is told of each element as it is expanded. */ -const reachable = ( - start: I["element"], - outgoing: ReadonlyMap[]>, - skip: ReadonlySet, - observe: ((element: I["element"]) => void) | undefined, -): ReadonlySet => { - const seen = new Set([start]); - const stack: I["element"][] = [start]; - for (let next = stack.pop(); next !== undefined; next = stack.pop()) { - observe?.(next); - for (const transition of outgoing.get(next) ?? []) { - if (!skip.has(transition.id) && !seen.has(transition.target)) { - seen.add(transition.target); - stack.push(transition.target); - } - } - } - - return seen; -}; - -/** - * The fragments the model's structure implies at the decisions and forks the scenario passes, restricted to the elements on the scenario path. - * - * A transition leaving a decision is a loop transition when its target can reach the decision again, the same rule `graph-domain` uses for a legal cycle. Since the decision reaches that target through the transition, this holds exactly when the target is in the decision's strongly connected component, which is found for every element at once by one pass of Tarjan's algorithm. A decision with loop transitions yields one `loop` whose body is its whole component, which is every element on a cycle through it, guarded by its loop transitions' guards joined with "or", since taking any of them goes round again. The decision's other (forward) branches, when there are at least two, yield an `alt` with one operand per branch; when there are exactly two and one of them goes straight to where the branches meet, the fragment is an `opt` around the other. A fork pseudo-state's branches yield a `par`. A branch's body is the elements reachable from it, over forward transitions only, that are not reachable from every branch: those that every branch reaches lie after the branches meet. - * - * The work is one pass over every element and transition for the components, then, for each decision or fork the scenario passes, one walk over forward transitions per forward branch. Loops add no walks, and decisions and forks off the path add none. - * @param model - The model the scenario runs through. - * @param onPath - The elements the scenario passes, to which every body is restricted. - * @param observe - Told of each element a branch walk expands, so a caller can measure the work done. - * @returns Derived fragments in model order of the decision or fork they come from, each decision's loop before its alternatives. - */ -export const deriveFragments = ( - model: Model, - onPath: ReadonlySet, - observe?: (element: I["element"]) => void, -): readonly DerivedFragment[] => { - const outgoing = outgoingMap(model); - const graph = buildDag( - model.elements.map((element) => element.id), - (id) => (outgoing.get(id) ?? []).map((transition) => transition.target), - ); - const component = new Map(); - for (const members of stronglyConnectedComponents(graph)) { - for (const member of members) { - component.set(member, members); - } - } - - const loops = new Set(); - for (const element of model.elements) { - if (element.kind !== "decision") { - continue; - } - // Elements share a component exactly when they map to the same members list, so this is a constant-time test per transition. - const own = component.get(element.id); - for (const transition of outgoing.get(element.id) ?? []) { - if (own !== undefined && component.get(transition.target) === own) { - loops.add(transition.id); - } - } - } - - const onPathOnly = ( - elements: Readonly>, - ): I["element"][] => [...elements].filter((id) => onPath.has(id)); - - /** One operand per branch, with the branches' shared tail removed, and whether each branch goes straight to where the branches meet. */ - const branchesOf = ( - branches: readonly TransitionOf[], - ): readonly { - readonly operand: DerivedOperand; - readonly direct: boolean; - }[] => { - const reach = branches.map((branch) => ({ - branch, - elements: reachable(branch.target, outgoing, loops, observe), - })); - // An element every branch reaches lies after the branches meet; it is enough to test those the first branch reaches. - const [head] = reach; - const shared = new Set( - head === undefined - ? [] - : [...head.elements].filter((id) => - reach.every(({ elements }) => elements.has(id)), - ), - ); - - return reach.map(({ branch, elements }) => ({ - operand: { - guard: branch.guard, - body: onPathOnly([...elements].filter((id) => !shared.has(id))), - }, - direct: shared.has(branch.target), - })); - }; - - const derived: DerivedFragment[] = []; - for (const element of model.elements) { - if (!onPath.has(element.id)) { - continue; - } - const leaving = outgoing.get(element.id) ?? []; - if (element.kind === "decision") { - const back = leaving.filter((transition) => loops.has(transition.id)); - const own = component.get(element.id); - if (back.length > 0 && own !== undefined) { - const guards = [ - ...new Set( - back.flatMap((transition) => - transition.guard === undefined ? [] : [transition.guard], - ), - ), - ]; - derived.push({ - element: element.id, - operator: "loop", - operands: [ - { - guard: guards.length > 0 ? guards.join(" or ") : undefined, - body: onPathOnly(own), - }, - ], - }); - } - const forward = leaving.filter((transition) => !loops.has(transition.id)); - if (forward.length < minBranches) { - continue; - } - const branches = branchesOf(forward); - const [taken, ...others] = branches.filter(({ direct }) => !direct); - derived.push( - branches.length === minBranches && - taken !== undefined && - others.length === 0 - ? { element: element.id, operator: "opt", operands: [taken.operand] } - : { - element: element.id, - operator: "alt", - operands: branches.map(({ operand }) => operand), - }, - ); - } else if ( - element.kind === "pseudo" && - element.role === "fork" && - leaving.length >= minBranches - ) { - derived.push({ - element: element.id, - operator: "par", - operands: branchesOf(leaving).map(({ operand }) => operand), - }); - } - } - - return derived; -}; diff --git a/packages/core/graph/projection/src/derived-fragments.unit.test.ts b/packages/core/graph/projection/src/derived-fragments.unit.test.ts deleted file mode 100644 index d35f5d1..0000000 --- a/packages/core/graph/projection/src/derived-fragments.unit.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { Model, OpenIds } from "@exadev/process-graph-schema"; -import { describe, expect, it } from "vitest"; -import { deriveFragments } from "./derived-fragments.ts"; - -/** Enough loop branches that a walk per branch, or per loop element, costs far more than one pass over the graph. */ -const loopBranches = 300; - -const branches = Array.from( - { length: loopBranches }, - (_, index) => `l-${String(index)}`, -); - -/** One decision with many ways round and back to it, and two ways out. */ -const retrying: Model = { - participants: [], - events: [], - actions: [], - artefacts: [], - elements: [ - { kind: "activity", id: "a", label: "A" }, - { kind: "decision", id: "d", label: "D" }, - ...branches.map((id) => ({ kind: "activity" as const, id, label: id })), - { kind: "activity", id: "x", label: "X" }, - { kind: "activity", id: "y", label: "Y" }, - ], - 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" }, - ], - fragments: [], - projections: [], -}; - -const everything = new Set(retrying.elements.map((element) => element.id)); - -describe("deriveFragments on a decision with many loop branches", () => { - it("derives one loop over the decision's whole cycle and one alt for its ways out", () => { - const [loop, alt, ...rest] = deriveFragments(retrying, everything); - - expect(rest).toEqual([]); - expect(loop?.element).toBe("d"); - expect(loop?.operator).toBe("loop"); - expect(loop?.operands.map((operand) => operand.guard)).toEqual(["again"]); - expect(new Set(loop?.operands[0]?.body)).toEqual( - new Set(["d", ...branches]), - ); - expect(alt).toEqual({ - element: "d", - operator: "alt", - operands: [ - { guard: "done", body: ["x"] }, - { guard: "gave up", body: ["y"] }, - ], - }); - }); - - it("expands no more elements than the graph has elements and transitions", () => { - let expanded = 0; - deriveFragments(retrying, everything, () => { - expanded += 1; - }); - - expect(expanded).toBeLessThanOrEqual( - retrying.elements.length + retrying.transitions.length, - ); - }); - - it("does no walks for a scenario that passes no decision or fork", () => { - let expanded = 0; - const derived = deriveFragments(retrying, new Set(), () => { - expanded += 1; - }); - - expect(derived).toEqual([]); - expect(expanded).toBe(0); - }); -}); diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts new file mode 100644 index 0000000..808043e --- /dev/null +++ b/packages/core/graph/projection/src/fragments.ts @@ -0,0 +1,608 @@ +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; +} + +/** A fragment that may be drawn, with the positions of the messages each of its operands holds, each list ascending and the lists disjoint. */ +interface Candidate { + /** The authored fragment's id; absent for a derived fragment. */ + readonly authored: I["fragment"] | undefined; + readonly operands: readonly { + readonly guard: string | undefined; + readonly positions: readonly number[]; + }[]; + /** 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); + } +}; + +const range = (from: number, to: number): number[] => + Array.from({ length: to - from }, (_, offset) => from + offset); + +/** + * 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: Model, + steps: readonly Step[], + entries: readonly Entry[], + onPath: ReadonlySet, +): 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)) { + 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"), + }); + } + + const forwardCount = new Map[]>(); + for (const element of branching) { + forwardCount.set( + element.id, + (outgoing.get(element.id) ?? []).filter( + (transition) => !loops.has(transition.id), + ), + ); + } + 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, positions: range(occurrence.start, stop) }, + ], + 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 forward = forwardCount.get(source); + + if ( + decisions.has(source) && + forward !== undefined && + forward.length >= minBranches && + !loops.has(transition.id) + ) { + const earlier = openAt.get(source); + if (earlier !== undefined) { + close(earlier, step.before); + } + const meet = meetOf(source); + const direct = forward.filter((branch) => branch.target === meet); + const operator = + forward.length === minBranches && direct.length === 1 ? "opt" : "alt"; + 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) && + forward !== undefined && + forward.length >= minBranches + ) { + let parallel = openParallel.get(source); + if (parallel === undefined || parallel.taken.has(transition.id)) { + parallel = { + element: source, + meet: meetOf(source), + 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) { + 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. Bodies are indexed by element once, so each message is matched only against the operands whose body holds the element it arrives at. + */ +const authoredCandidates = ( + fragments: readonly Fragment[], + entries: readonly Entry[], + onPath: ReadonlySet, +): Candidate[] => { + const operands = fragments.map((fragment) => + fragment.operands.map( + ( + operand, + ): { + readonly guard: string | undefined; + readonly positions: number[]; + readonly counts: boolean; + } => ({ + guard: operand.guard, + positions: [], + counts: + operand.body.length > 0 && operand.body.every((id) => onPath.has(id)), + }), + ), + ); + const holders = new Map< + I["element"], + { readonly fragment: number; readonly operand: number }[] + >(); + for (const [fragment, { operands: authored }] of fragments.entries()) { + for (const [operand, { body }] of authored.entries()) { + if (operands[fragment]?.[operand]?.counts !== true) { + continue; + } + for (const id of new Set(body)) { + append(holders, id, { fragment, operand }); + } + } + } + for (const entry of entries) { + let previous = -1; + for (const { fragment, operand } of holders.get(entry.target) ?? []) { + if (fragment !== previous) { + previous = fragment; + operands[fragment]?.[operand]?.positions.push(entry.index); + } + } + } + + return fragments.map(({ id, operator }, fragment) => ({ + authored: id, + operands: (operands[fragment] ?? []).filter( + ({ positions }) => positions.length > 0, + ), + 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; + 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); +}; + +/** + * 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. + * + * Each position records the innermost fragment placed over it, so placing a fragment reads its two ends and writes its own span, and the items are then built in one pass over the positions; the work is the number of messages times the depth fragments nest to. + * @returns The items and the ids of the authored fragments drawn. + */ +export const wrapFragments = ( + model: Model, + steps: readonly Step[], + entries: readonly Entry[], + onPath: ReadonlySet, +): { + readonly items: SequenceItem[]; + readonly drawn: Set; +} => { + const candidates = [ + ...authoredCandidates(model.fragments, entries, onPath), + ...deriveCandidates(model, steps, entries, onPath), + ]; + + 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 { positions } of candidate.operands) { + count += positions.length; + const head = positions[0]; + const tail = positions.at(-1); + if (head !== undefined && tail !== undefined) { + first = Math.min(first, head); + last = Math.max(last, tail); + } + } + 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 innermost: (Placed | undefined)[] = entries.map(() => undefined); + const placed: Placed[] = []; + 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); + const positions = outer.candidate.operands[slot]?.positions ?? []; + if (countBetween(positions, first, last) !== last - first + 1) { + continue; + } + parent = { placed: outer, slot }; + } + const owners = new Map(); + if (candidate.operands.length > 1) { + for (const [slot, { positions }] of candidate.operands.entries()) { + for (const position of positions) { + owners.set(position, slot); + } + } + } + const node: Placed = { + candidate, + first, + last, + slotOf: (position) => owners.get(position) ?? 0, + parent, + order, + }; + placed.push(node); + for (let position = first; position <= last; position += 1) { + innermost[position] = 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) { + 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/sequence.ts b/packages/core/graph/projection/src/sequence.ts index e9f54c2..841c856 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -6,7 +6,7 @@ import type { Transition, } from "@exadev/process-graph-schema"; import { actorMap, type ElementOf } from "./actors.ts"; -import { deriveFragments } from "./derived-fragments.ts"; +import { wrapFragments, type Entry, type Step } from "./fragments.ts"; import { commonLoss, type Loss } from "./loss.ts"; export interface SequenceMessage { @@ -58,186 +58,6 @@ export interface SequenceDiagram { 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[]; - /** The scenario positions of `entries`, for constant-time membership. */ - readonly indices: ReadonlySet; -} - -/** A place fragments are drawn into: the top level, or one operand of another fragment. */ -interface Container { - readonly children: Placed[]; -} - -/** A fragment that may be drawn: authored or derived. */ -interface Candidate { - /** The authored fragment's id; absent for a derived fragment. */ - readonly authored: I["fragment"] | undefined; - /** Builds the item drawn for it from the operands the scenario takes. */ - readonly item: (operands: readonly SequenceOperand[]) => SequenceItem; - readonly operands: readonly { - readonly guard?: string | undefined; - readonly body: readonly I["element"][]; - }[]; -} - -/** A fragment the scenario passes through, with a container for the fragments nested inside each of its operands. */ -interface Placed { - readonly candidate: Candidate; - readonly operands: readonly TakenOperand[]; - readonly indices: ReadonlySet; - readonly nested: readonly Container[]; -} - -/** Whether two sets of message positions are the same. */ -const sameIndices = ( - first: ReadonlySet, - second: ReadonlySet, -): boolean => - first.size === second.size && [...first].every((index) => second.has(index)); - -/** - * 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 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 their own 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. A derived fragment holding exactly the messages an authored one holds is not drawn either: the authored fragment is the author's notation for the same structure. - * - * A message belongs to an operand when the element it arrives at is in that operand's body. - */ -const wrapFragments = ( - fragments: readonly Candidate[], - 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 body = new Set(operand.body); - const inside = entries.filter( - (entry) => body.has(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, - indices: new Set(inside.map((entry) => entry.index)), - }); - } - // A fold rather than a spread into Math.min and Math.max, which would pass every position as an argument. - let first = Number.POSITIVE_INFINITY; - let last = Number.NEGATIVE_INFINITY; - for (const index of claimed) { - first = Math.min(first, index); - last = Math.max(last, index); - } - const duplicate = - fragment.authored === undefined && - candidates.some( - (placed) => - placed.candidate.authored !== undefined && - sameIndices(placed.indices, claimed), - ); - if ( - operands.length > 0 && - last - first + 1 === claimed.size && - !duplicate - ) { - candidates.push({ - candidate: 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: [] }; - // Descends into the operand of the first overlapping fragment that holds all of `node`'s messages; an overlap no operand holds is not drawn. - const place = (node: Placed): void => { - const positions = [...node.indices]; - for ( - let container: Container | undefined = top; - container !== undefined; - ) { - const sibling: Placed | undefined = container.children.find((child) => - positions.some((index) => child.indices.has(index)), - ); - if (sibling === undefined) { - container.children.push(node); - - return; - } - const slot: number = sibling.operands.findIndex((operand) => - positions.every((index) => operand.indices.has(index)), - ); - container = slot === -1 ? undefined : sibling.nested[slot]; - } - }; - for (const node of bySize) { - place(node); - } - - 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); - if (owner.candidate.authored !== undefined) { - drawn.add(owner.candidate.authored); - } - items.push( - owner.candidate.item( - 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. * @@ -276,6 +96,7 @@ export const toSequence = ( 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(); @@ -295,6 +116,7 @@ export const toSequence = ( if (transition === undefined) { throw new Error(`Scenario step "${id}" is not a declared transition`); } + steps.push({ transition, before: entries.length }); const source = elements.get(transition.source); const target = elements.get(transition.target); const previous = arrivedAt; @@ -373,31 +195,7 @@ export const toSequence = ( (transition) => !taken.has(transition.id) && onPath.has(transition.source), ); - const candidates: Candidate[] = [ - ...model.fragments.map(({ id, operator, operands }): Candidate => ({ - authored: id, - operands, - item: (drawnOperands) => ({ - kind: "fragment", - fragment: id, - operator, - operands: drawnOperands, - }), - })), - ...deriveFragments(model, onPath).map( - ({ element, operator, operands }): Candidate => ({ - authored: undefined, - operands, - item: (drawnOperands) => ({ - kind: "fragment", - element, - operator, - operands: drawnOperands, - }), - }), - ), - ]; - const { items, drawn } = wrapFragments(candidates, entries, onPath); + const { items, drawn } = wrapFragments(model, steps, entries, onPath); const loss: Loss[] = [ ...commonLoss(model, "sequence", { From 5decfe7d4d79cc661960d34b1b3f0df7f42a408d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:41:49 +0100 Subject: [PATCH 12/33] refactor(projection): settle each branching element's shape once How many forward branches a decision or fork has, where they meet and whether a decision draws an opt are now found once per element before the path is read, rather than recomputed for every step that leaves it. --- .../core/graph/projection/src/fragments.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts index 808043e..bda5ae3 100644 --- a/packages/core/graph/projection/src/fragments.ts +++ b/packages/core/graph/projection/src/fragments.ts @@ -197,14 +197,26 @@ const deriveCandidates = ( }); } - const forwardCount = new Map[]>(); + /** 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) { - forwardCount.set( - element.id, - (outgoing.get(element.id) ?? []).filter( - (transition) => !loops.has(transition.id), - ), + 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) => @@ -279,22 +291,19 @@ const deriveCandidates = ( const drawn = steps[index + 1]?.before ?? entries.length; const position = drawn > step.before ? step.before : undefined; const source = transition.source; - const forward = forwardCount.get(source); + const shape = shapes.get(source); if ( decisions.has(source) && - forward !== undefined && - forward.length >= minBranches && + shape !== undefined && + shape.branches >= minBranches && !loops.has(transition.id) ) { const earlier = openAt.get(source); if (earlier !== undefined) { close(earlier, step.before); } - const meet = meetOf(source); - const direct = forward.filter((branch) => branch.target === meet); - const operator = - forward.length === minBranches && direct.length === 1 ? "opt" : "alt"; + const { meet, operator } = shape; if (!(operator === "opt" && transition.target === meet)) { const occurrence: Occurrence = { element: source, @@ -312,14 +321,14 @@ const deriveCandidates = ( let label = labels.get(source); if ( !decisions.has(source) && - forward !== undefined && - forward.length >= minBranches + shape !== undefined && + shape.branches >= minBranches ) { let parallel = openParallel.get(source); if (parallel === undefined || parallel.taken.has(transition.id)) { parallel = { element: source, - meet: meetOf(source), + meet: shape.meet, outer: label, operands: [], taken: new Set(), From c98450d0bbe21d6eee78737f9e5fa97687b6ec44 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 18:41:54 +0100 Subject: [PATCH 13/33] fix(projection): index folded elements once and build chain lists when read Every chain copied its run of folded elements, so a long run of messages fanning out to many ends, or entered from many sources, cost the run's length once per chain. Each projection now indexes the folded elements once: their components are condensed, each component's reachable drawn elements are found in reverse topological order and shared along runs with nothing to add, and the elements entered from exactly one folded element form a forest recording the last trigger, effect and guard above each, so a single route's labels come from its two ends. A chain's transitions, through and joined guards, and an edge's label, are built only when read. The inverses match edges and transitions by their first transition and end, read from the projection's own record, and act on the steps from folded elements into an end once however many chains share them. --- packages/core/graph/projection/README.md | 10 +- .../projection/src/bounded-work.unit.test.ts | 93 +++ packages/core/graph/projection/src/chains.ts | 541 ++++++++++++++---- .../graph/projection/src/chains.unit.test.ts | 24 +- .../core/graph/projection/src/flowchart.ts | 139 +++-- .../core/graph/projection/src/handoffs.ts | 33 +- .../graph/projection/src/inverse-flowchart.ts | 165 ++++-- .../projection/src/inverse-state-machine.ts | 158 ++--- .../graph/projection/src/state-machine.ts | 166 +++--- .../core/graph/projection/src/swimlane.ts | 7 +- 10 files changed, 979 insertions(+), 357 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 347c896..a598d76 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -29,7 +29,9 @@ A `message` element is read by every projection. A sequence step arriving at one 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. The walk enters each folded element once per starting transition and follows each transition leaving it once, and each target's union is gathered by walking back once over the transitions met, so the work grows with the elements and transitions reached and the number of targets, never with the number of routes; a transition back to a folded element still on the walk's path closes a cycle and ends there. On a single route the guards are joined with "and" and the last trigger and effect are kept; where several routes are merged their guards, triggers and effects belong to different routes, so a flowchart edge is labelled only with the messages it carries and a state machine transition keeps only the target's entry action. +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. + +Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are found once, in reverse topological order; a component with nothing of its own to add shares its successor's list, so the work is the model's size plus the sizes of the lists merged where folded elements branch, however many chains start upstream. 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 @@ -60,7 +62,7 @@ Fragments are derived from the model's structure as the scenario passes its deci 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. 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. 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. Placing a fragment marks the span it covers, so the work of nesting is the number of messages times the depth fragments nest to. +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. Bodies are indexed by element, so matching costs each message once per authored operand whose body holds the element it arrives at. 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. 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. Placing a fragment marks the span it covers, so the work of nesting is the number of messages times the depth fragments nest to. ## Loss reports @@ -91,7 +93,9 @@ Each view has an inverse that takes the view after an edit and returns a `ModelD | `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 or moving its target acts on each step arriving at its end), 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. +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. 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. diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 3341888..4f7f0b9 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -3,7 +3,11 @@ import { describe, expect, it } from "vitest"; import { applyModelDiff, diffFlowchart, + handoffs, + toFlowchart, toSequence, + toStateMachine, + toSwimlane, type FlowEdge, type FlowNode, type ModelDiff, @@ -259,3 +263,92 @@ describe("toSequence's derived fragments", () => { ]); }); }); + +/** 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(", ")); + }); +}); diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 88ff5ca..4aaa4ea 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -1,3 +1,7 @@ +import { + buildDag, + stronglyConnectedComponents, +} from "@exadev/process-graph-dag"; import type { IdSpace, Model, Transition } from "@exadev/process-graph-schema"; export type TransitionOf = Transition; @@ -19,137 +23,480 @@ export const outgoingMap = ( 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 element through folded elements: from a drawn element to the next drawn element, or, when the folded elements form a cycle, to the folded element the cycle returns to. Several routes from the same first transition to the same target are one chain. + * 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 { - /** Every transition on some route from the first transition to `target`, each once, in the order the walk first met them. The first transition comes first. */ - readonly transitions: readonly TransitionOf[]; - /** Every folded element on some route to `target`, each once, in the order the walk first met them. Empty when the chain is a single transition. */ - readonly through: readonly I["element"][]; + readonly first: TransitionOf; readonly target: I["element"]; - /** Whether more than one route leads to `target`, so `transitions` is a union of routes rather than one route in order. */ + /** 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; +} + +/** 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; +} + +/** 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, in the order a depth-first walk reaches 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; } +/** 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; + }; +}; + /** - * The chains that start with `first`: one for each element it leads to through folded elements, in the order the walk first reaches them. + * Indexes a model's folded elements so chains can be read off for any first transition. * - * The walk is depth-first over each element's outgoing transitions in model order, with an explicit stack and a visited set, so each folded element is entered once and each transition leaving one is followed once, and `folded` is asked once per element reached. Routes through different folded elements that reach the same target are collapsed into one chain rather than enumerated, so the number of chains is at most the number of elements reached, whatever the number of routes. A transition that arrives back at a folded element still on the walk's stack closes a cycle; that element is then also a chain target, which no notation draws because it is folded. + * The folded elements are condensed into strongly connected components in one pass, and each component's reachable accepted targets are found once, in reverse topological order: a component with no target of its own and one way on shares its successor's list, so a long run of folded elements holds one list, not one per element. 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. * - * After the walk, each target's `transitions` and `through` are found by walking back from it over the transitions met, so building every chain takes at most the number of targets times the number of transitions met. - * @param first - The transition the chains start with. - * @param outgoing - The transitions leaving each element. - * @param folded - Whether the notation folds an element away instead of drawing it. - * @returns One chain per target, in the order the walk first reaches each target. + * Merged chains' lists, and every chain's lists, are built only when read. */ -export const chainsFrom = ( - first: TransitionOf, - outgoing: ReadonlyMap[]>, - folded: (element: I["element"]) => boolean, -): readonly Chain[] => { - const isFolded = new Map(); - const foldedOnce = (element: I["element"]): boolean => { - const known = isFolded.get(element); - if (known !== undefined) { - return known; - } - const answer = folded(element); - isFolded.set(element, answer); - - return answer; - }; - - /** Folded elements entered, in order, and whether each is still on the stack (`open`) or finished. */ - const state = new Map(); - const entered: I["element"][] = []; - const met: TransitionOf[] = []; - const targets: I["element"][] = []; - const isTarget = new Set(); - const addTarget = (element: I["element"]): void => { - if (!isTarget.has(element)) { - isTarget.add(element); - targets.push(element); +export const chainIndex = ( + model: Model, + folding: Folding, +): ChainIndex => { + const outgoing = outgoingMap(model); + const isFolded = new Set(); + for (const element of model.elements) { + if (folding.folded(element.id)) { + isFolded.add(element.id); } - }; - const arrive = (transition: TransitionOf): boolean => { - met.push(transition); - const { target } = transition; - if (!foldedOnce(target)) { - addTarget(target); + } + const events = new Map(); + for (const id of isFolded) { + const event = folding.eventOf?.(id); + if (event !== undefined) { + events.set(id, event); + } + } - return false; + const foldedIn = new Map[]>(); + const arrivingIn = new Map[]>(); + for (const transition of model.transitions) { + if (!isFolded.has(transition.source)) { + continue; } - const seen = state.get(target); - if (seen === "open") { - addTarget(target); + append( + isFolded.has(transition.target) ? foldedIn : arrivingIn, + transition.target, + transition, + ); + } + + 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); } - if (seen !== undefined) { - return false; + } + 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); + } } - state.set(target, "open"); - entered.push(target); - - return true; - }; - - const stack: { readonly element: I["element"]; next: number }[] = []; - if (arrive(first)) { - stack.push({ element: first.target, next: 0 }); } - for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) { - const transition = (outgoing.get(top.element) ?? [])[top.next]; - if (transition === undefined) { - state.set(top.element, "done"); - stack.pop(); + + // Each component's accepted targets, in reverse topological order so every successor's list is ready; a list is shared, not copied, when nothing new would be added to it. + const targets: (readonly I["element"][])[] = []; + for (const [index, members] of components.entries()) { + const own: I["element"][] = []; + const next = new Set(); + const leaving = members.flatMap((member) => outgoing.get(member) ?? []); + for (const { target } of leaving) { + const successor = componentOf.get(target); + const list = successor === undefined ? undefined : targets[successor]; + if (!isFolded.has(target) && folding.accepted(target)) { + own.push(target); + } else if (successor !== index && list !== undefined) { + next.add(list); + } + } + const [only, ...others] = [...next]; + if (own.length === 0 && only !== undefined && others.length === 0) { + targets[index] = only; continue; } - top.next += 1; - if (arrive(transition)) { - stack.push({ element: transition.target, next: 0 }); + const merged = new Set(own); + for (const list of next) { + for (const target of list) { + merged.add(target); + } } + targets[index] = [...merged]; } - const incoming = new Map[]>(); - for (const transition of met) { - const into = incoming.get(transition.target); - if (into === undefined) { - incoming.set(transition.target, [transition]); - } else { - into.push(transition); + // 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, ...rest] = into; + if ( + link !== undefined && + rest.length === 0 && + !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 + ); + }; - return targets.map((target) => { - // The folded elements that lead to `target`, found by walking back over the transitions met; only folded elements are walked through, as the routes only pass through those. + /** The union of every route from `start` to `target`, by a depth-first walk from `start` and a walk back from `target` over what it met. */ + const union = ( + first: TransitionOf, + target: I["element"], + ): { + readonly transitions: readonly TransitionOf[]; + readonly through: readonly I["element"][]; + } => { + const met: TransitionOf[] = [first]; + const entered: I["element"][] = [first.target]; + const seen = new Set([first.target]); + const stack: { readonly id: I["element"]; next: number }[] = [ + { id: first.target, 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; + met.push(transition); + if (isFolded.has(transition.target) && !seen.has(transition.target)) { + seen.add(transition.target); + entered.push(transition.target); + stack.push({ id: transition.target, next: 0 }); + } + } + const into = new Map[]>(); + for (const transition of met) { + append(into, transition.target, transition); + } const leading = new Set([target]); const back: I["element"][] = [target]; for (let at = back.pop(); at !== undefined; at = back.pop()) { - for (const transition of incoming.get(at) ?? []) { - if (state.has(transition.source) && !leading.has(transition.source)) { + for (const transition of into.get(at) ?? []) { + if (seen.has(transition.source) && !leading.has(transition.source)) { leading.add(transition.source); back.push(transition.source); } } } - const transitions = met.filter((transition) => - leading.has(transition.target), - ); - const through = entered.filter((element) => leading.has(element)); - const leaving = new Map(); - for (const transition of transitions) { - leaving.set(transition.source, (leaving.get(transition.source) ?? 0) + 1); - } return { - transitions, - through, - target, - merged: - transitions.length !== through.length + 1 || - through.some( - (element) => element !== target && leaving.get(element) !== 1, - ), + transitions: met.filter((transition) => leading.has(transition.target)), + through: entered.filter((id) => leading.has(id)), }; - }); + }; + + 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 ? [] : (targets[component] ?? []); + const level = depth.get(start); + + return reached.map((target): Chain => { + const [last, ...more] = arrivingIn.get(target) ?? []; + const source = last?.source; + const single = + last !== undefined && + source !== undefined && + more.length === 0 && + 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 (isFolded.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 ( + isFolded.has(transition.target) && + !reached.has(transition.target) + ) { + reached.add(transition.target); + stack.push(transition.target); + } + } + } + const leading = new Set(); + for (const id of reached) { + const component = componentOf.get(id); + if (component !== undefined && (targets[component] ?? []).length > 0) { + leading.add(id); + } + } + + return leading; + }; + + return { + from, + arriving: (element) => arrivingIn.get(element) ?? [], + carried, + }; }; diff --git a/packages/core/graph/projection/src/chains.unit.test.ts b/packages/core/graph/projection/src/chains.unit.test.ts index d5d01c9..10583ad 100644 --- a/packages/core/graph/projection/src/chains.unit.test.ts +++ b/packages/core/graph/projection/src/chains.unit.test.ts @@ -1,6 +1,6 @@ import type { Model, OpenIds } from "@exadev/process-graph-schema"; import { describe, expect, it } from "vitest"; -import { chainsFrom, outgoingMap } from "./chains.ts"; +import { chainIndex } from "./chains.ts"; import { diffFlowchart, handoffs, @@ -86,27 +86,31 @@ const layered: Model = { const pairs = sources.flatMap((source) => ends.map((end) => [source, end])); describe("chains through a layered acyclic graph of message elements", () => { - it("visits each folded element at most once per first transition", () => { - const outgoing = outgoingMap(layered); + 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] : [], ), ); - for (const first of layered.transitions.filter((transition) => - sources.includes(transition.source), - )) { - const visits = new Map(); - const chains = chainsFrom(first, outgoing, (element) => { + 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([...visits.values()].every((count) => count === 1)).toBe(true); 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", () => { diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index e0e0121..0ba752d 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -1,6 +1,12 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; -import { chainsFrom, outgoingMap, type Chain } from "./chains.ts"; +import { + chainIndex, + once, + type Chain, + type ChainIndex, + type TransitionOf, +} from "./chains.ts"; import { commonLoss, unrepresentedLoss, type Loss } from "./loss.ts"; export interface FlowNode { @@ -62,62 +68,97 @@ const flowNode = ( * 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 flow - The nodes and edges the projection drew; a message element an edge carries is shown. + * @param nodes - The nodes the projection drew. + * @param carried - The message elements some edge carries, which are shown. */ export const flowchartLoss = ( model: Model, notation: "flowchart" | "swimlane", - flow: Readonly, "nodes" | "edges">>, + nodes: readonly FlowNode[], + carried: ReadonlySet, ): readonly Loss[] => [ - ...commonLoss(model, notation, { - fragments: new Set(), - messages: new Set(flow.edges.flatMap((edge) => edge.through ?? [])), - }), + ...commonLoss(model, notation, { fragments: new Set(), messages: carried }), ...unrepresentedLoss( model, notation, - new Set(flow.nodes.map((node) => node.id)), + new Set(nodes.map((node) => node.id)), ), ]; -/** The edge a chain of transitions draws. On a single route its label is the route's guards joined with "and", or, when none is guarded, the carried messages' labels. Where several routes are merged the guards belong to different routes and cannot be joined, so the label is the carried messages' labels only. */ +/** 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; +} + +/** + * 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. + */ const edgeOf = ( - from: I["element"], chain: Chain, messageLabels: ReadonlyMap, ): FlowEdge => { - const guards = chain.transitions.flatMap((transition) => - transition.guard === undefined ? [] : [transition.guard], + 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 carried = chain.through.flatMap((id) => { - const label = messageLabels.get(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 label === undefined ? [] : [label]; + return message === undefined ? [] : [message]; + }) + .join(", "); }); - const label = - guards.length > 0 && !chain.merged - ? guards.join(" and ") - : carried.length > 0 - ? carried.join(", ") - : undefined; return { from, to: chain.target, - ...(label === undefined ? {} : { label }), - transitions: chain.transitions.map((transition) => transition.id), - ...(chain.through.length === 0 ? {} : { through: chain.through }), + get label(): string { + return label(); + }, + get transitions(): readonly I["transition"][] { + return ids(); + }, + get through(): readonly I["element"][] { + return chain.through(); + }, }; }; /** - * 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 drawn element it leads to, however many routes through message elements connect them. 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. + * The flowchart and the parts its inverse needs. * @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 = ( +export const flowchartParts = ( model: Model, -): Flowchart => { +): FlowchartParts => { const nodes: FlowNode[] = []; const messageLabels = new Map(); for (const element of model.elements) { @@ -131,23 +172,39 @@ export const toFlowchart = ( } const included = new Set(nodes.map((node) => node.id)); - const outgoing = outgoingMap(model); - const isMessage = (id: I["element"]): boolean => messageLabels.has(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)) { - continue; - } - for (const chain of chainsFrom(transition, outgoing, isMessage)) { - if (included.has(chain.target)) { - edges.push(edgeOf(transition.source, chain, messageLabels)); - } + 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, edges }), + flowchart: { + nodes, + edges, + loss: flowchartLoss(model, "flowchart", nodes, carried), + }, + 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/handoffs.ts b/packages/core/graph/projection/src/handoffs.ts index 526ddce..b5d0fff 100644 --- a/packages/core/graph/projection/src/handoffs.ts +++ b/packages/core/graph/projection/src/handoffs.ts @@ -1,13 +1,13 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import { actorMap } from "./actors.ts"; -import { chainsFrom, outgoingMap } from "./chains.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, in order, when it is carried by a message. */ + /** 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"][]; } @@ -25,8 +25,10 @@ export const handoffs = ( element.kind === "message" ? [element.id] : [], ), ); - const outgoing = outgoingMap(model); - const isMessage = (id: I["element"]): boolean => messages.has(id); + const index = chainIndex(model, { + folded: (id) => messages.has(id), + accepted: (id) => actorOf.has(id), + }); const found: Handoff[] = []; for (const transition of model.transitions) { @@ -34,16 +36,23 @@ export const handoffs = ( if (from === undefined) { continue; } - for (const chain of chainsFrom(transition, outgoing, isMessage)) { + for (const chain of index.from(transition)) { const to = actorOf.get(chain.target); - if (to !== undefined && from !== to) { - found.push({ - transition: transition.id, - from, - to, - ...(chain.through.length === 0 ? {} : { through: chain.through }), - }); + 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/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts index 3fc4c47..6748487 100644 --- a/packages/core/graph/projection/src/inverse-flowchart.ts +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -2,13 +2,14 @@ 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 { toFlowchart, type FlowEdge, type FlowNode } from "./flowchart.ts"; +import { + flowchartParts, + type EdgeOrigin, + type FlowEdge, + type FlowNode, +} from "./flowchart.ts"; import type { Swimlane } from "./swimlane.ts"; -/** The key an edge is matched by across the original and the edited view: the transitions it stands for. */ -const edgeKey = (edge: FlowEdge): string => - JSON.stringify(edge.transitions); - /** How many edited edges arrive at and leave each node, counted in one pass over the edges. */ interface Degrees { readonly in: ReadonlyMap; @@ -85,7 +86,8 @@ const diffFlow = ( }>, actorOf: (node: I["element"]) => I["actor"] | undefined, ): Pick, "elements" | "transitions"> => { - const original = toFlowchart(model); + const parts = flowchartParts(model); + const original = parts.flowchart; const elements = new Map>( model.elements.map((element) => [element.id, element]), ); @@ -138,34 +140,64 @@ const diffFlow = ( } const transitionChanges: TransitionChange[] = []; - const originalEdges = new Map>( - original.edges.map((edge) => [edgeKey(edge), edge]), - ); - const editedKeys = new Set(edited.edges.map(edgeKey)); const transitions = new Map( model.transitions.map((transition) => [transition.id, transition]), ); - /** The transitions of an edge that arrive at its drawn end: the edge's one transition, or the last step of each route through the messages it carries. */ - const arriving = (edge: FlowEdge): TransitionOf[] => - edge.transitions.flatMap((id) => { - const transition = transitions.get(id); + // 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 transition?.target === edge.to ? [transition] : []; - }); - for (const edge of original.edges) { - if (!editedKeys.has(edgeKey(edge))) { - // An edge that carries messages shares its earlier steps with every other edge through the same messages, so only the steps arriving at its end are removed, and which of them is a choice: inferred. - for (const transition of arriving(edge)) { + return undefined; + }; + const editedKeys = new Set(edited.edges.map(editedKey)); + /** The transitions arriving at an edge's end: its one transition, or every step from a message into its end. */ + const arriving = (origin: EdgeOrigin): readonly TransitionOf[] => + origin.first.target === origin.target + ? [origin.first] + : parts.index.arriving(origin.target); + const removed = new Set(); + for (const [key, { origin }] of originals) { + if (editedKeys.has(key)) { + continue; + } + // An edge that carries messages shares its earlier steps with every other edge through the same messages, so only the steps arriving at its end are removed, and which of them is a choice: inferred. + for (const transition of arriving(origin)) { + if (!removed.has(transition.id)) { + removed.add(transition.id); transitionChanges.push({ kind: "removed", transition: transition.id, - inferred: edge.transitions.length > 1, + inferred: origin.first.target !== origin.target, }); } } } + const retargeted = new Map(); for (const edge of edited.edges) { - const before = originalEdges.get(edgeKey(edge)); + const key = editedKey(edge); + const before = key === undefined ? undefined : originals.get(key); const [first, ...rest] = edge.transitions; if (before === undefined) { if ( @@ -175,7 +207,7 @@ const diffFlow = ( edge.through !== undefined ) { throw new Error( - `Edge ${edgeKey(edge)} is neither an edge of the model nor a new edge with a single new transition id`, + `Edge ${JSON.stringify(edge.transitions)} is neither an edge of the model nor a new edge with a single new transition id`, ); } transitionChanges.push({ @@ -190,55 +222,66 @@ const diffFlow = ( }); continue; } - const head = first === undefined ? undefined : transitions.get(first); - if (head === undefined) { - continue; - } - const chain = edge.transitions.length > 1; - if (!chain && (before.from !== edge.from || before.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, - }); - } else { - if (before.from !== edge.from) { + 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: head.target }, + to: { source: edge.from, target: edge.to }, inferred: false, }); } - if (before.to !== edge.to) { - // The arriving steps are shared with every other edge through the same messages, so moving them is a choice: inferred. - for (const tail of arriving(before)) { - transitionChanges.push({ - kind: "rewired", - transition: tail.id, - from: { source: tail.source, target: tail.target }, - to: { source: tail.source, target: edge.to }, - inferred: true, - }); - } + 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 (before.label !== edge.label) { - if (chain) { + 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 however many of those edges moved. + for (const tail of arriving(origin)) { + const already = retargeted.get(tail.id); + if (already === edge.to) { + continue; + } + if (already !== undefined) { throw new Error( - `Edge ${edgeKey(edge)} carries a message; edit the message or its steps rather than the edge's label`, + `Edges ending at "${origin.target}" through the same steps were moved to different ends, "${already}" and "${edge.to}"`, ); } + retargeted.set(tail.id, edge.to); transitionChanges.push({ - kind: "changed", - field: "guard", - transition: head.id, - from: head.guard, - to: edge.label, - inferred: false, + kind: "rewired", + transition: tail.id, + from: { source: tail.source, target: tail.target }, + to: { source: tail.source, target: edge.to }, + inferred: true, }); } } @@ -249,11 +292,11 @@ const diffFlow = ( /** * 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 the transitions it stands for: 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 arriving at its end and is inferred. + * 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, or a relabelled edge that carries a message. + * @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, diff --git a/packages/core/graph/projection/src/inverse-state-machine.ts b/packages/core/graph/projection/src/inverse-state-machine.ts index 09d07f9..ff2abda 100644 --- a/packages/core/graph/projection/src/inverse-state-machine.ts +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -2,18 +2,14 @@ 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 { - toStateMachine, + stateMachineParts, type StateMachine, type StateMachineState, type StateMachineTransition, } from "./state-machine.ts"; -/** The key a transition is matched by across the original and the edited machine: the model transitions it stands for. */ -const transitionKey = ( - transition: StateMachineTransition, -): string => JSON.stringify(transition.transitions); - /** The element an added state becomes: an authored `state`, or an initial or final pseudo-state. */ const addedState = ( state: StateMachineState, @@ -31,7 +27,7 @@ const addedState = ( * * 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 the model transitions it stands for. 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 that arrives at its target (one per route) and is marked `inferred`. A new transition must carry one new transition id and is added with its event, guard and effect. + * 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. @@ -41,7 +37,8 @@ export const diffStateMachine = ( model: Model, edited: Readonly, "subject" | "states" | "transitions">>, ): ModelDiff => { - const original = toStateMachine(model, edited.subject); + const parts = stateMachineParts(model, edited.subject); + const original = parts.machine; const elements = new Map>( model.elements.map((element) => [element.id, element]), ); @@ -95,36 +92,73 @@ export const diffStateMachine = ( const transitions = new Map( model.transitions.map((transition) => [transition.id, transition]), ); - const originals = new Map>( - original.transitions.map((transition) => [ - transitionKey(transition), - 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), ); - const editedKeys = new Set(edited.transitions.map(transitionKey)); + /** 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[] = []; - /** The model transitions of a drawn transition that arrive at its target state: its one transition, or the last step of each route through the elements folded into it. */ - const arriving = ( - drawnTransition: StateMachineTransition, - ): TransitionOf[] => - drawnTransition.transitions.flatMap((id) => { - const transition = transitions.get(id); - - return transition?.target === drawnTransition.target ? [transition] : []; - }); - for (const drawnTransition of original.transitions) { - if (!editedKeys.has(transitionKey(drawnTransition))) { - for (const tail of arriving(drawnTransition)) { - transitionChanges.push({ - kind: "removed", - transition: tail.id, - inferred: drawnTransition.transitions.length > 1, - }); - } + // 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); + } + }; + for (const [key, { origin }] of originals) { + if (editedKeys.has(key)) { + continue; + } + for (const tail of arriving(origin)) { + push({ + kind: "removed", + transition: tail.id, + inferred: origin.first.target !== origin.target, + }); } } for (const edit of edited.transitions) { - const before = originals.get(transitionKey(edit)); + const key = editedKey(edit); + const before = key === undefined ? undefined : originals.get(key); const [first, ...rest] = edit.transitions; if (before === undefined) { if ( @@ -134,10 +168,10 @@ export const diffStateMachine = ( edit.through !== undefined ) { throw new Error( - `Transition ${transitionKey(edit)} is neither a transition of the model nor a new transition with a single new id`, + `Transition ${JSON.stringify(edit.transitions)} is neither a transition of the model nor a new transition with a single new id`, ); } - transitionChanges.push({ + push({ kind: "added", transition: { id: first, @@ -151,26 +185,24 @@ export const diffStateMachine = ( }); continue; } - const head = first === undefined ? undefined : transitions.get(first); - if (head === undefined) { - continue; - } - const tails = arriving(before); - const chain = edit.transitions.length > 1; + const { origin, drawn: previous } = before; + const head = origin.first; + const chain = head.target !== origin.target; + const tails = arriving(origin); if ( !chain && - (before.source !== edit.source || before.target !== edit.target) + (previous.source !== edit.source || previous.target !== edit.target) ) { - transitionChanges.push({ + push({ kind: "rewired", transition: head.id, from: { source: head.source, target: head.target }, to: { source: edit.source, target: edit.target }, inferred: false, }); - } else { - if (before.source !== edit.source) { - transitionChanges.push({ + } else if (chain) { + if (previous.source !== edit.source) { + push({ kind: "rewired", transition: head.id, from: { source: head.source, target: head.target }, @@ -178,20 +210,18 @@ export const diffStateMachine = ( inferred: false, }); } - if (before.target !== edit.target) { - for (const tail of tails) { - transitionChanges.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.target === edit.target ? [] : tails) { + 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 before.event === edit.event ? [] : tails) { - transitionChanges.push({ + for (const tail of previous.event === edit.event ? [] : tails) { + push({ kind: "changed", field: "trigger", transition: tail.id, @@ -200,8 +230,8 @@ export const diffStateMachine = ( inferred: chain, }); } - for (const tail of before.guard === edit.guard ? [] : tails) { - transitionChanges.push({ + for (const tail of previous.guard === edit.guard ? [] : tails) { + push({ kind: "changed", field: "guard", transition: tail.id, @@ -210,15 +240,15 @@ export const diffStateMachine = ( inferred: chain, }); } - if (before.effect !== edit.effect) { - const target = elements.get(before.target); + if (previous.effect !== edit.effect) { + const target = elements.get(previous.target); if (target?.kind === "activity" && target.action !== undefined) { throw new Error( - `The effect of ${transitionKey(edit)} is the action of "${target.id}"; change that activity's action instead`, + `The effect of ${JSON.stringify(edit.transitions)} is the action of "${target.id}"; change that activity's action instead`, ); } for (const tail of tails) { - transitionChanges.push({ + push({ kind: "changed", field: "effect", transition: tail.id, diff --git a/packages/core/graph/projection/src/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index 7197ae7..7afdf23 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -1,6 +1,14 @@ import type { IdSpace, Model } from "@exadev/process-graph-schema"; import type { ElementOf } from "./actors.ts"; -import { chainsFrom, outgoingMap, type Chain } from "./chains.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 { @@ -53,27 +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. - * - * 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. + * 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. - * @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 = ( +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, @@ -99,55 +102,55 @@ export const toStateMachine = ( (element.kind === "activity" && scoped && element.affects?.includes(subject) !== true); - const isFolded = (id: I["element"]): boolean => { - const element = elements.get(id); - - return element !== undefined && isTransparent(element); - }; - /** 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. */ - const transitionOf = ( - source: I["element"], - chain: Chain, - ): StateMachineTransition => { - const triggers: I["event"][] = []; - const guards: string[] = []; - const effects: I["action"][] = []; - for (const transition of chain.merged ? [] : chain.transitions) { - if (transition.trigger !== undefined) { - triggers.push(transition.trigger); - } - if (transition.guard !== undefined) { - guards.push(transition.guard); - } - if (transition.effect !== undefined) { - effects.push(transition.effect); - } - const arrived = elements.get(transition.target); - if ( - transition.target !== chain.target && - arrived?.kind === "message" && - arrived.event !== undefined - ) { - triggers.push(arrived.event); - } - } + /** 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 = triggers.at(-1); - const guard = guards.length > 0 ? guards.join(" and ") : undefined; + const event = chain.trigger; const effect = - (target?.kind === "activity" ? target.action : undefined) ?? - effects.at(-1); - - return { - source, + (target?.kind === "activity" ? target.action : undefined) ?? chain.effect; + const base = { + source: chain.first.source, target: chain.target, ...(event === undefined ? {} : { event }), - ...(guard === undefined ? {} : { guard }), ...(effect === undefined ? {} : { effect }), - transitions: chain.transitions.map((transition) => transition.id), - ...(chain.through.length === 0 ? {} : { through: chain.through }), }; + if (chain.first.target === chain.target) { + const { guard } = chain.first; + + return { + ...base, + ...(guard === undefined ? {} : { guard }), + transitions: [chain.first.id], + }; + } + 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(); + }, + } + : { + ...base, + get transitions(): readonly I["transition"][] { + return ids(); + }, + get through(): readonly I["element"][] { + return chain.through(); + }, + }; }; const states: StateMachineState[] = []; @@ -160,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) { @@ -168,16 +182,14 @@ export const toStateMachine = ( } states.push(state); for (const transition of outgoing.get(element.id) ?? []) { - for (const chain of chainsFrom(transition, outgoing, isFolded)) { - if (stateIds.has(chain.target)) { - transitions.push(transitionOf(element.id, chain)); - } + firsts.push(transition); + for (const chain of index.from(transition)) { + transitions.push(transitionOf(chain)); + origins.push({ first: transition, target: chain.target }); } } } - const carried = new Set( - transitions.flatMap((transition) => transition.through ?? []), - ); + const carried = index.carried(firsts); const loss: Loss[] = [ ...commonLoss(model, "stateMachine", { @@ -201,5 +213,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/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index b249fc1..61839d2 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,6 @@ export const toSwimlane = (model: Model): Swimlane => { lanes, unassigned, handoffs: handoffs(model), - loss: flowchartLoss(model, "swimlane", flow), + loss: flowchartLoss(model, "swimlane", flow.nodes, parts.carried), }; }; From b1b132966451f38cb558219eac54ac1cc4f25eac Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 21:11:22 +0100 Subject: [PATCH 14/33] fix(projection): hold derived spans by their ends and place fragments by them A derived alt or opt listed every position it held, so decisions nested inside one another that all meet at the path's end kept positions quadratic in the nesting. An operand now holds either a run, by its ends, or a list, and fragments are compared and placed by the ends of their spans: a segment tree records when each placed fragment covers a node, and the innermost fragment at a position is the latest placement on its path to the root. No span is expanded into its positions, and which operand holds a position is a map lookup for listed positions and a binary search over runs. --- packages/core/graph/projection/README.md | 2 +- .../projection/src/bounded-work.unit.test.ts | 143 +++++++++++ .../core/graph/projection/src/fragments.ts | 231 ++++++++++++++---- .../core/graph/projection/src/sequence.ts | 43 +++- 4 files changed, 362 insertions(+), 57 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index a598d76..5056989 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -62,7 +62,7 @@ Fragments are derived from the model's structure as the scenario passes its deci 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. Bodies are indexed by element, so matching costs each message once per authored operand whose body holds the element it arrives at. 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. 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. Placing a fragment marks the span it covers, so the work of nesting is the number of messages times the depth fragments nest to. +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. Bodies are indexed by element, so matching costs each message once per authored operand whose body holds the element it arrives at. 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. 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 diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 4f7f0b9..b8bb861 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -1,5 +1,6 @@ import type { Model, OpenIds } from "@exadev/process-graph-schema"; import { describe, expect, it } from "vitest"; +import { sequenceObserved } from "./sequence.ts"; import { applyModelDiff, diffFlowchart, @@ -352,3 +353,145 @@ describe("chains through runs of message elements", () => { 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", + }, + }, + ]); + }); +}); diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts index bda5ae3..991d28c 100644 --- a/packages/core/graph/projection/src/fragments.ts +++ b/packages/core/graph/projection/src/fragments.ts @@ -29,14 +29,26 @@ export interface Step { readonly before: number; } -/** A fragment that may be drawn, with the positions of the messages each of its operands holds, each list ascending and the lists disjoint. */ +/** 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 positions: readonly number[]; + readonly from?: never; + readonly to?: 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 { - readonly guard: string | undefined; - readonly positions: readonly number[]; - }[]; + readonly operands: readonly Operand[]; /** Builds the item drawn for it from its operands' items. */ readonly item: (operands: readonly SequenceOperand[]) => SequenceItem; } @@ -70,8 +82,17 @@ const append = ( } }; -const range = (from: number, to: number): number[] => - Array.from({ length: to - from }, (_, offset) => from + offset); +/** 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. @@ -82,12 +103,13 @@ const range = (from: number, to: number): number[] => * * 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: Model, - steps: readonly Step[], - entries: readonly Entry[], - onPath: ReadonlySet, -): Candidate[] => { +const deriveCandidates = ({ + model, + steps, + entries, + onPath, + observe, +}: ScenarioPath): Candidate[] => { const branching = model.elements.filter( (element) => onPath.has(element.id) && @@ -180,6 +202,7 @@ const deriveCandidates = ( for (const entry of entries) { const own = component.get(entry.target); if (own !== undefined && looping.has(own)) { + observe?.(1); append(inside, own, entry.index); } } @@ -246,7 +269,7 @@ const deriveCandidates = ( add(occurrence.element, { authored: undefined, operands: [ - { guard: occurrence.guard, positions: range(occurrence.start, stop) }, + { guard: occurrence.guard, from: occurrence.start, to: stop - 1 }, ], item: derivedItem(occurrence.element, occurrence.operator), }); @@ -343,6 +366,7 @@ const deriveCandidates = ( // 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); @@ -373,6 +397,7 @@ const authoredCandidates = ( fragments: readonly Fragment[], entries: readonly Entry[], onPath: ReadonlySet, + observe: Observe | undefined, ): Candidate[] => { const operands = fragments.map((fragment) => fragment.operands.map( @@ -409,6 +434,7 @@ const authoredCandidates = ( for (const { fragment, operand } of holders.get(entry.target) ?? []) { if (fragment !== previous) { previous = fragment; + observe?.(1); operands[fragment]?.[operand]?.positions.push(entry.index); } } @@ -463,26 +489,144 @@ const countBetween = ( 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.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 => + held.positions === undefined + ? held.from <= first && last <= held.to + : countBetween(held.positions, first, last) === last - first + 1; + +/** + * 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[], + observe: Observe | undefined, +): ((position: number) => number) => { + if (operands.length === 1) { + return () => 0; + } + const listed = new Map(); + const runs: { + readonly from: number; + readonly to: number; + readonly slot: number; + }[] = []; + for (const [slot, operand] of operands.entries()) { + 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 found = listed.get(position); + 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. * - * Each position records the innermost fragment placed over it, so placing a fragment reads its two ends and writes its own span, and the items are then built in one pass over the positions; the work is the number of messages times the depth fragments nest to. + * 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 = ( - model: Model, - steps: readonly Step[], - entries: readonly Entry[], - onPath: ReadonlySet, + path: ScenarioPath, ): { readonly items: SequenceItem[]; readonly drawn: Set; } => { + const { model, entries, onPath, observe } = path; const candidates = [ - ...authoredCandidates(model.fragments, entries, onPath), - ...deriveCandidates(model, steps, entries, onPath), + ...authoredCandidates(model.fragments, entries, onPath, observe), + ...deriveCandidates(path), ]; const spans: { @@ -496,13 +640,12 @@ export const wrapFragments = ( let first = Number.POSITIVE_INFINITY; let last = Number.NEGATIVE_INFINITY; let count = 0; - for (const { positions } of candidate.operands) { - count += positions.length; - const head = positions[0]; - const tail = positions.at(-1); - if (head !== undefined && tail !== undefined) { - first = Math.min(first, head); - last = Math.max(last, tail); + 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)}`; @@ -520,42 +663,37 @@ export const wrapFragments = ( (a, b) => b.last - b.first - (a.last - a.first) || a.order - b.order, ); - const innermost: (Placed | undefined)[] = entries.map(() => undefined); + const tree = placementTree(entries.length); 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]) { + const outer = innermost(first); + if (outer !== innermost(last)) { continue; } let parent: Placed["parent"]; if (outer !== undefined) { const slot = outer.slotOf(first); - const positions = outer.candidate.operands[slot]?.positions ?? []; - if (countBetween(positions, first, last) !== last - first + 1) { + const operand = outer.candidate.operands[slot]; + if (operand === undefined || !holdsAll(operand, first, last)) { continue; } parent = { placed: outer, slot }; } - const owners = new Map(); - if (candidate.operands.length > 1) { - for (const [slot, { positions }] of candidate.operands.entries()) { - for (const position of positions) { - owners.set(position, slot); - } - } - } const node: Placed = { candidate, first, last, - slotOf: (position) => owners.get(position) ?? 0, + slotOf: slotIndex(candidate.operands, observe), parent, order, }; + tree.place(first, last, placed.length); placed.push(node); - for (let position = first; position <= last; position += 1) { - innermost[position] = 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. @@ -575,10 +713,11 @@ export const wrapFragments = ( 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]; + const owner = innermost(entry.index); contentOf( owner === undefined ? undefined diff --git a/packages/core/graph/projection/src/sequence.ts b/packages/core/graph/projection/src/sequence.ts index 841c856..997fc3d 100644 --- a/packages/core/graph/projection/src/sequence.ts +++ b/packages/core/graph/projection/src/sequence.ts @@ -6,7 +6,12 @@ import type { Transition, } from "@exadev/process-graph-schema"; import { actorMap, type ElementOf } from "./actors.ts"; -import { wrapFragments, type Entry, type Step } from "./fragments.ts"; +import { + wrapFragments, + type Entry, + type Observe, + type Step, +} from "./fragments.ts"; import { commonLoss, type Loss } from "./loss.ts"; export interface SequenceMessage { @@ -59,19 +64,15 @@ export interface SequenceDiagram { } /** - * 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`. + * `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. - * @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. + * @param observe - Told of work done over message positions. */ -export const toSequence = ( +export const sequenceObserved = ( model: Model, scenario: readonly I["transition"][], + observe: Observe | undefined, ): SequenceDiagram => { const actors = actorMap(model); const elements = new Map>( @@ -195,7 +196,13 @@ export const toSequence = ( (transition) => !taken.has(transition.id) && onPath.has(transition.source), ); - const { items, drawn } = wrapFragments(model, steps, entries, onPath); + const { items, drawn } = wrapFragments({ + model, + steps, + entries, + onPath, + ...(observe === undefined ? {} : { observe }), + }); const loss: Loss[] = [ ...commonLoss(model, "sequence", { @@ -222,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); From 38f4aa821735c7fe8be83875ee66e10443f147a5 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 21:11:29 +0100 Subject: [PATCH 15/33] docs: mention the projection inverses in the package table --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. | From a725ad3ba5229318b159f4c39baeda5bb5550198 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 21:25:26 +0100 Subject: [PATCH 16/33] fix(projection): hold each component's targets as a shared structure The chain index kept a list of reachable targets for every folded component, copying its successor's list, so a comb of messages each with its own exit stored references quadratic in its length. A component now either takes over the list of a successor nothing else extends and prepends its own targets as cells, or points at its own targets and its successors' structures. The index stores one reference per cell, own target and successor, linear in the elements and transitions; duplicates are removed when a component's targets are first read, and the read is remembered. --- packages/core/graph/projection/README.md | 2 +- packages/core/graph/projection/src/chains.ts | 176 +++++++++++++++--- .../graph/projection/src/chains.unit.test.ts | 156 ++++++++++++++++ 3 files changed, 309 insertions(+), 25 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 5056989..a59ab59 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -31,7 +31,7 @@ Flowchart edges and state machine transitions list the model `transitions` they 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. -Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are found once, in reverse topological order; a component with nothing of its own to add shares its successor's list, so the work is the model's size plus the sizes of the lists merged where folded elements branch, however many chains start upstream. 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. +Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are held as a shared structure built in reverse topological order: a component whose only way on leads to a list nothing else extends prepends its own targets to that list, and any other component points at its own targets and its successors' structures. Nothing is copied, so the index stores one reference per target cell, own target and successor, linear in the elements and transitions; duplicates are removed when a component's targets are first read, by a walk over the structure reachable from it that is remembered for every chain starting there. 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 diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 4aaa4ea..35ea462 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -59,6 +59,17 @@ export interface Chain { readonly effect: I["action"] | undefined; } +/** A cell of a list shared by every list that ends with it. */ +interface Cell { + readonly value: T; + readonly next: Cell | undefined; +} + +/** A component's reachable targets: a list of cells, possibly shared with the components after it, or its own targets and the structures of the components it leads to, read with duplicates removed. */ +type Reach = + | { readonly head: Cell | undefined } + | { readonly own: readonly T[]; readonly parts: readonly Reach[] }; + /** 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. */ @@ -67,6 +78,8 @@ export interface Folding { 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. */ @@ -109,7 +122,7 @@ export const once = (compute: () => T): (() => T) => { /** * Indexes a model's folded elements so chains can be read off for any first transition. * - * The folded elements are condensed into strongly connected components in one pass, and each component's reachable accepted targets are found once, in reverse topological order: a component with no target of its own and one way on shares its successor's list, so a long run of folded elements holds one list, not one per element. 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. + * The folded elements are condensed into strongly connected components in one pass. Each component's reachable accepted targets are held as a shared structure, never as a list per component: a component whose only way on leads to a list nothing else extends takes that list over and prepends its own targets as cells, and any other component points at its own targets and its successors' structures. The structure stores one reference per target cell, own target and successor, so its size is linear in the elements and transitions; duplicates are removed when a component's targets are first read, and that read is remembered. 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. */ @@ -174,34 +187,149 @@ export const chainIndex = ( } } - // Each component's accepted targets, in reverse topological order so every successor's list is ready; a list is shared, not copied, when nothing new would be added to it. - const targets: (readonly I["element"][])[] = []; + // Each component's accepted targets as a shared structure, built in reverse topological order so every successor's is ready. Nothing is copied: a list grows by prepending cells onto its successor's, and a merge only points at its parts. + const observe = folding.observe; + const successorsOf: number[][] = components.map(() => []); + const predecessors = components.map(() => 0); for (const [index, members] of components.entries()) { - const own: I["element"][] = []; - const next = new Set(); - const leaving = members.flatMap((member) => outgoing.get(member) ?? []); - for (const { target } of leaving) { - const successor = componentOf.get(target); - const list = successor === undefined ? undefined : targets[successor]; - if (!isFolded.has(target) && folding.accepted(target)) { - own.push(target); - } else if (successor !== index && list !== undefined) { - next.add(list); + 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; + } } } - const [only, ...others] = [...next]; - if (own.length === 0 && only !== undefined && others.length === 0) { - targets[index] = only; - continue; + } + const reaches: Reach[] = []; + // The set of a list's targets, for removing duplicates while building, held only by the one component still allowed to extend that list. + const owned = new Map>(); + const leads: boolean[] = []; + for (const [index, members] of components.entries()) { + const own: I["element"][] = []; + const ownSet = new Set(); + for (const member of members) { + for (const { target } of outgoing.get(member) ?? []) { + if ( + !isFolded.has(target) && + folding.accepted(target) && + !ownSet.has(target) + ) { + ownSet.add(target); + own.push(target); + } + } } - const merged = new Set(own); - for (const list of next) { - for (const target of list) { - merged.add(target); + const parts = (successorsOf[index] ?? []).filter( + (successor) => leads[successor] === true, + ); + leads[index] = own.length > 0 || parts.length > 0; + const [only, ...others] = parts; + const onlyReach = only === undefined ? undefined : reaches[only]; + const takeable = + only === undefined + ? undefined + : predecessors[only] === 1 + ? owned.get(only) + : undefined; + if (only === undefined) { + // A component with nowhere further to go starts a list of its own targets. + let head: Cell | undefined; + for (const target of own.toReversed()) { + observe?.(1); + head = { value: target, next: head }; + } + owned.set(index, ownSet); + observe?.(ownSet.size); + reaches[index] = { head }; + } else if ( + others.length === 0 && + takeable !== undefined && + onlyReach !== undefined && + "head" in onlyReach && + own.every((target) => !takeable.has(target)) + ) { + // The only way on leads to a list nothing else extends and that holds none of this component's own targets: take it over and prepend them, so they come first as they would in a merge. + owned.delete(only); + let head = onlyReach.head; + for (const target of own.toReversed()) { + observe?.(1); + takeable.add(target); + head = { value: target, next: head }; } + owned.set(index, takeable); + reaches[index] = { head }; + } else if ( + others.length === 0 && + own.length === 0 && + onlyReach !== undefined + ) { + reaches[index] = onlyReach; + } else { + observe?.(own.length + parts.length); + reaches[index] = { + own, + parts: parts.flatMap((part) => { + const reach = reaches[part]; + + return reach === undefined ? [] : [reach]; + }), + }; } - targets[index] = [...merged]; } + // Read once per component asked about: the targets in order, own before successors', each once, walking every shared cell and part only once. + const readTargets = new Map(); + const targetsOf = (component: number): readonly I["element"][] => { + const known = readTargets.get(component); + if (known !== undefined) { + return known; + } + const start = reaches[component]; + const found: I["element"][] = []; + const listed = new Set(); + const visited = new Set | Cell>(); + const stack: Reach[] = start === undefined ? [] : [start]; + for (let next = stack.pop(); next !== undefined; next = stack.pop()) { + if (visited.has(next)) { + continue; + } + visited.add(next); + if ("head" in next) { + for ( + let cell = next.head; + cell !== undefined && !visited.has(cell); + cell = cell.next + ) { + visited.add(cell); + if (!listed.has(cell.value)) { + listed.add(cell.value); + found.push(cell.value); + } + } + continue; + } + for (const target of next.own) { + if (!listed.has(target)) { + listed.add(target); + found.push(target); + } + } + for (const part of next.parts.toReversed()) { + stack.push(part); + } + } + observe?.(found.length); + readTargets.set(component, found); + + return found; + }; // 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>(); @@ -366,7 +494,7 @@ export const chainIndex = ( : []; } const component = componentOf.get(start); - const reached = component === undefined ? [] : (targets[component] ?? []); + const reached = component === undefined ? [] : targetsOf(component); const level = depth.get(start); return reached.map((target): Chain => { @@ -486,7 +614,7 @@ export const chainIndex = ( const leading = new Set(); for (const id of reached) { const component = componentOf.get(id); - if (component !== undefined && (targets[component] ?? []).length > 0) { + if (component !== undefined && leads[component] === true) { leading.add(id); } } diff --git a/packages/core/graph/projection/src/chains.unit.test.ts b/packages/core/graph/projection/src/chains.unit.test.ts index 10583ad..4d90ec2 100644 --- a/packages/core/graph/projection/src/chains.unit.test.ts +++ b/packages/core/graph/projection/src/chains.unit.test.ts @@ -8,6 +8,9 @@ import { 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. */ @@ -207,3 +210,156 @@ describe("a merged chain's labels", () => { ]); }); }); + +/** 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); + }); +}); From 2e2997eb670a39295bf60cca4313d51630281014 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:20:12 +0100 Subject: [PATCH 17/33] fix(projection): share each component's deduplicated targets with every start A component whose own exits a later component already reached could not extend that list, so it fell back to a structure that every start walked again, and many starts into one run did work quadratic in its length. Each component's targets are now a list of cells holding each target once, shared with the components after it, kept free of duplicates while building by a layered set: extended in place by the sole way into its way on, otherwise laid thinly over it, and over the last way on at a merge. A component lists the own targets nothing further on reaches, then those further on. The index stays linear in the elements and transitions, and reading a start walks only its own targets. --- packages/core/graph/projection/README.md | 4 +- packages/core/graph/projection/src/chains.ts | 188 +++++++++---------- 2 files changed, 90 insertions(+), 102 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index a59ab59..715f942 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -31,7 +31,7 @@ Flowchart edges and state machine transitions list the model `transitions` they 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. -Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are held as a shared structure built in reverse topological order: a component whose only way on leads to a list nothing else extends prepends its own targets to that list, and any other component points at its own targets and its successors' structures. Nothing is copied, so the index stores one reference per target cell, own target and successor, linear in the elements and transitions; duplicates are removed when a component's targets are first read, by a walk over the structure reachable from it that is remembered for every chain starting there. 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. +Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are a list holding each once, shared with the components after it: a component prepends to its way on's list the targets that list lacks, its own first, then those of other ways on, a target reached by several ways on coming with the last of them. Duplicates are kept out while building by a layered set, extended in place by a component that is the sole way into its way on and otherwise laid thinly over it. Nothing is copied, so the index is linear in the elements and transitions, and reading a component's targets walks exactly its own list, however many starts share the same suffix. 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 @@ -62,7 +62,7 @@ Fragments are derived from the model's structure as the scenario passes its deci 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. Bodies are indexed by element, so matching costs each message once per authored operand whose body holds the element it arrives at. 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. 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. +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. 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 diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 35ea462..d11555a 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -65,10 +65,17 @@ interface Cell { readonly next: Cell | undefined; } -/** A component's reachable targets: a list of cells, possibly shared with the components after it, or its own targets and the structures of the components it leads to, read with duplicates removed. */ -type Reach = - | { readonly head: Cell | undefined } - | { readonly own: readonly T[]; readonly parts: readonly Reach[] }; +/** A layer of a set of targets over the set it extends. */ +interface Layer { + readonly extra: Set; + readonly base: Layer | undefined; +} + +/** A component's reachable targets: the head of its list of cells, which holds each target once, and the layered set of the same targets. */ +interface Reached { + readonly head: Cell | undefined; + readonly set: Layer; +} /** What a notation folds away and what it draws. */ export interface Folding { @@ -84,7 +91,7 @@ export interface Folding { /** 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, in the order a depth-first walk reaches them. */ + /** 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[]; @@ -122,7 +129,7 @@ export const once = (compute: () => T): (() => T) => { /** * Indexes a model's folded elements so chains can be read off for any first transition. * - * The folded elements are condensed into strongly connected components in one pass. Each component's reachable accepted targets are held as a shared structure, never as a list per component: a component whose only way on leads to a list nothing else extends takes that list over and prepends its own targets as cells, and any other component points at its own targets and its successors' structures. The structure stores one reference per target cell, own target and successor, so its size is linear in the elements and transitions; duplicates are removed when a component's targets are first read, and that read is remembered. 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. + * The folded elements are condensed into strongly connected components in one pass. Each component's reachable accepted targets are a list of cells holding each target once, shared with the components after it, and a layered set of the same targets used only while building: a component that is the sole way into its one way on extends that set in place and prepends the targets it adds, any other component lays a thin set of its own targets over its way on's, and a merge prepends onto the last way on's list the targets of the others it lacks. Nothing is copied, so the index stores one reference per cell, set entry and layer, linear in the elements and transitions, and reading a component's targets walks exactly its own list, however many starts read the same suffix. 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. */ @@ -208,9 +215,32 @@ export const chainIndex = ( } } } - const reaches: Reach[] = []; - // The set of a list's targets, for removing duplicates while building, held only by the one component still allowed to extend that list. - const owned = new Map>(); + // Each component's targets as a list of cells free of duplicates, shared with the components after it, and a layered set of the same targets for removing duplicates while building. A layer is extended in place only by the one component that is the sole way into its owner; any other component adds a thin layer of its own over it, so nothing is copied. + const byComponent: Reached[] = []; + const has = ( + set: Layer | undefined, + target: I["element"], + ): boolean => { + for (let layer = set; layer !== undefined; layer = layer.base) { + if (layer.extra.has(target)) { + return true; + } + } + + return false; + }; + const prepend = ( + targets: readonly I["element"][], + tail: Cell | undefined, + ): Cell | undefined => { + let head = tail; + for (const target of targets.toReversed()) { + observe?.(1); + head = { value: target, next: head }; + } + + return head; + }; const leads: boolean[] = []; for (const [index, members] of components.entries()) { const own: I["element"][] = []; @@ -227,108 +257,66 @@ export const chainIndex = ( } } } - const parts = (successorsOf[index] ?? []).filter( - (successor) => leads[successor] === true, - ); + const parts = (successorsOf[index] ?? []).flatMap((successor) => { + const part = byComponent[successor]; + + return leads[successor] === true && part !== undefined + ? [{ successor, part }] + : []; + }); leads[index] = own.length > 0 || parts.length > 0; - const [only, ...others] = parts; - const onlyReach = only === undefined ? undefined : reaches[only]; - const takeable = - only === undefined - ? undefined - : predecessors[only] === 1 - ? owned.get(only) - : undefined; - if (only === undefined) { - // A component with nowhere further to go starts a list of its own targets. - let head: Cell | undefined; - for (const target of own.toReversed()) { - observe?.(1); - head = { value: target, next: head }; + const last = parts.at(-1); + if (last === undefined) { + observe?.(own.length); + byComponent[index] = { + head: prepend(own, undefined), + set: { extra: ownSet, base: undefined }, + }; + continue; + } + // Targets of the ways on other than the last that the last does not reach, in order; the last way on's list is the shared tail. + const added = new Set(); + const fresh: I["element"][] = []; + for (const { part } of parts.slice(0, -1)) { + for (let cell = part.head; cell !== undefined; cell = cell.next) { + if (!has(last.part.set, cell.value) && !added.has(cell.value)) { + added.add(cell.value); + fresh.push(cell.value); + } } - owned.set(index, ownSet); - observe?.(ownSet.size); - reaches[index] = { head }; - } else if ( - others.length === 0 && - takeable !== undefined && - onlyReach !== undefined && - "head" in onlyReach && - own.every((target) => !takeable.has(target)) - ) { - // The only way on leads to a list nothing else extends and that holds none of this component's own targets: take it over and prepend them, so they come first as they would in a merge. - owned.delete(only); - let head = onlyReach.head; - for (const target of own.toReversed()) { + } + const ownFresh = own.filter( + (target) => !has(last.part.set, target) && !added.has(target), + ); + const additions = [...ownFresh, ...fresh]; + const head = prepend(additions, last.part.head); + if (parts.length === 1 && predecessors[last.successor] === 1) { + // The sole way into the last way on: extend its set in place. + for (const target of additions) { observe?.(1); - takeable.add(target); - head = { value: target, next: head }; + last.part.set.extra.add(target); } - owned.set(index, takeable); - reaches[index] = { head }; - } else if ( - others.length === 0 && - own.length === 0 && - onlyReach !== undefined - ) { - reaches[index] = onlyReach; + byComponent[index] = { head, set: last.part.set }; } else { - observe?.(own.length + parts.length); - reaches[index] = { - own, - parts: parts.flatMap((part) => { - const reach = reaches[part]; - - return reach === undefined ? [] : [reach]; - }), + observe?.(additions.length); + byComponent[index] = { + head, + set: { extra: new Set(additions), base: last.part.set }, }; } } - // Read once per component asked about: the targets in order, own before successors', each once, walking every shared cell and part only once. - const readTargets = new Map(); + /** A component's targets in order: walking its cells, which hold each target once. */ const targetsOf = (component: number): readonly I["element"][] => { - const known = readTargets.get(component); - if (known !== undefined) { - return known; - } - const start = reaches[component]; - const found: I["element"][] = []; - const listed = new Set(); - const visited = new Set | Cell>(); - const stack: Reach[] = start === undefined ? [] : [start]; - for (let next = stack.pop(); next !== undefined; next = stack.pop()) { - if (visited.has(next)) { - continue; - } - visited.add(next); - if ("head" in next) { - for ( - let cell = next.head; - cell !== undefined && !visited.has(cell); - cell = cell.next - ) { - visited.add(cell); - if (!listed.has(cell.value)) { - listed.add(cell.value); - found.push(cell.value); - } - } - continue; - } - for (const target of next.own) { - if (!listed.has(target)) { - listed.add(target); - found.push(target); - } - } - for (const part of next.parts.toReversed()) { - stack.push(part); - } + const listed: I["element"][] = []; + for ( + let cell = byComponent[component]?.head; + cell !== undefined; + cell = cell.next + ) { + listed.push(cell.value); } - observe?.(found.length); - readTargets.set(component, found); - return found; + return listed; }; // The route forest: a folded element outside every cycle entered from exactly one folded element that is also outside every cycle hangs below it. From 3b9ca01bfc44bc5d234d31a5e94f31a7aa73d38d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:20:16 +0100 Subject: [PATCH 18/33] fix(projection): share each element's message positions across authored fragments Matching authored fragments appended a position for every fragment and message pair, so many fragments over a repeated step did work in the product of the two. The positions arriving at each element are now found once and shared by every operand whose body holds that element; an operand is its elements and their lists, and its extent, coverage and which operand holds a position are read from them without expanding. --- .../core/graph/projection/src/fragments.ts | 147 +++++++++++------- 1 file changed, 94 insertions(+), 53 deletions(-) diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts index 991d28c..784cba1 100644 --- a/packages/core/graph/projection/src/fragments.ts +++ b/packages/core/graph/projection/src/fragments.ts @@ -35,11 +35,21 @@ type Held = readonly from: number; readonly to: number; readonly positions?: never; + readonly elements?: never; } | { readonly positions: readonly number[]; readonly from?: never; - readonly to?: 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; @@ -391,7 +401,7 @@ const deriveCandidates = ({ }; /** - * 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. Bodies are indexed by element once, so each message is matched only against the operands whose body holds the element it arrives at. + * 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[], @@ -399,59 +409,52 @@ const authoredCandidates = ( onPath: ReadonlySet, observe: Observe | undefined, ): Candidate[] => { - const operands = fragments.map((fragment) => - fragment.operands.map( - ( - operand, - ): { - readonly guard: string | undefined; - readonly positions: number[]; - readonly counts: boolean; - } => ({ - guard: operand.guard, - positions: [], - counts: - operand.body.length > 0 && operand.body.every((id) => onPath.has(id)), - }), - ), - ); - const holders = new Map< - I["element"], - { readonly fragment: number; readonly operand: number }[] - >(); - for (const [fragment, { operands: authored }] of fragments.entries()) { - for (const [operand, { body }] of authored.entries()) { - if (operands[fragment]?.[operand]?.counts !== true) { + // 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; } - for (const id of new Set(body)) { - append(holders, id, { fragment, operand }); - } - } - } - for (const entry of entries) { - let previous = -1; - for (const { fragment, operand } of holders.get(entry.target) ?? []) { - if (fragment !== previous) { - previous = fragment; + const elements: { + readonly element: string; + readonly positions: readonly number[]; + }[] = []; + for (const element of operand.body) { observe?.(1); - operands[fragment]?.[operand]?.positions.push(entry.index); + 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 fragments.map(({ id, operator }, fragment) => ({ - authored: id, - operands: (operands[fragment] ?? []).filter( - ({ positions }) => positions.length > 0, - ), - item: (drawn): SequenceItem => ({ - kind: "fragment", - fragment: id, - operator, - operands: drawn, - }), - })); + 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. */ @@ -495,6 +498,18 @@ const extentOf = ( ): | { 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 @@ -509,28 +524,47 @@ const extentOf = ( }; /** Whether an operand holds every position from `first` to `last`. */ -const holdsAll = (held: Held, first: number, last: number): boolean => - held.positions === undefined +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; +}; /** * 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; @@ -543,7 +577,10 @@ const slotIndex = ( runs.sort((a, b) => a.from - b.from); return (position) => { - const found = listed.get(position); + const element = elementAt(position); + const found = + listed.get(position) ?? + (element === undefined ? undefined : byElement.get(element)); if (found !== undefined) { return found; } @@ -688,7 +725,11 @@ export const wrapFragments = ( candidate, first, last, - slotOf: slotIndex(candidate.operands, observe), + slotOf: slotIndex( + candidate.operands, + (position) => entries[position]?.target, + observe, + ), parent, order, }; From 691203560aaf7ea9864341470d9dcc6d5275416e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:20:20 +0100 Subject: [PATCH 19/33] test(projection): check every reported quadratic shape scales linearly A table crosses each shape found to need quadratic work (a comb of messages, a diamond of combs, decisions nested to one meet, many sources into one run, a run fanning out, repeated steps under many authored fragments, a wide fork, branches sharing one tail, a decision with many ways round) with every projection, and the edit lists with the inverses. Work is counted over the collection operations the projections are built from and measured per item of input and output, which must stay level as each shape doubles. --- .../projection/src/bounded-work.unit.test.ts | 339 ++++++++++++++++++ .../graph/projection/src/test-support/work.ts | 66 ++++ 2 files changed, 405 insertions(+) create mode 100644 packages/core/graph/projection/src/test-support/work.ts diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index b8bb861..94a6717 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -1,6 +1,7 @@ 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 { applyModelDiff, diffFlowchart, @@ -495,3 +496,341 @@ describe("nested derived fragments", () => { ]); }); }); + +/** + * 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 quadratic in their model, such as a state machine whose every state leads to every other through one folded decision; 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 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, +}); + +interface Shape { + readonly model: Model; + readonly path: readonly string[]; +} + +/** Messages in a line, each with its own exit, entered from one activity. */ +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. */ +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. */ +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. */ +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. */ +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", + ], + }; +}; + +const withEntity = ({ + model, + path, +}: { + readonly model: Model; + readonly path: readonly string[]; +}): Shape => ({ + model: { ...model, participants: actors }, + path, +}); + +const shapes: readonly (readonly [string, (count: number) => Shape])[] = [ + ["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], + ["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; + }, + ], + [ + "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. */ +const edits: readonly (readonly [string, (count: number) => () => unknown])[] = + [ + [ + "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 () => diffFlowchart(empty, edited); + }, + ], + [ + "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 () => applyModelDiff(model, diff); + }, + ], + ]; + +const doubled = 2; + +describe("work at most doubles when a reported shape doubles", () => { + describe.each(shapes)("%s", (_shape, build) => { + const small = build(size); + const large = build(doubled * size); + + it.each(entries)("through %s", (_entry, enter) => { + expect(workPerItem(large, enter)).toBeLessThan( + (growthLimit / doubled) * workPerItem(small, enter), + ); + }); + }); + + it.each(edits)("%s", (_edit, prepare) => { + const small = prepare(size); + const large = prepare(doubled * size); + + expect(workOf(large)).toBeLessThan(growthLimit * workOf(small)); + }); +}); 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..455c631 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/work.ts @@ -0,0 +1,66 @@ +/** + * Counts the work a call does through the collection operations every projection is built from: each `push`, `Set.add` and `Map.set` counts one, 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); + wrap(Set.prototype, "add", one); + wrap(Map.prototype, "set", one); + try { + run(); + } finally { + for (const restore of restores.toReversed()) { + restore(); + } + } + + return work; +}; From 08b9cde46fce7aaf2aa3f8476b5d4c6c7332fa7b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:40:50 +0100 Subject: [PATCH 20/33] fix(projection): build flat target arrays only where they are read The layered sets behind each component's targets grew a layer at every merge, so serial diamonds of messages rescanned every earlier target through an ever deeper set. A component now gets one flat array of its targets only when it is read on its own: it is entered from an element that is not folded, or from two or more components. Every other component is read as part of the one before it, so it is visited once in all. Arrays are built on demand and remembered; a generation stamp per target removes duplicates in constant time, a component with nothing to add shares the array it reads, and the only builder of an array nothing else reads extends it in place. The work is linear in the elements and transitions plus the arrays that must be copied, and the reading order is unchanged. --- packages/core/graph/projection/README.md | 4 +- packages/core/graph/projection/src/chains.ts | 280 ++++++++++++------- 2 files changed, 182 insertions(+), 102 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 715f942..88929c5 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -31,7 +31,7 @@ Flowchart edges and state machine transitions list the model `transitions` they 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. -Each projection indexes the folded elements once. They are condensed into strongly connected components, and each component's reachable drawn elements are a list holding each once, shared with the components after it: a component prepends to its way on's list the targets that list lacks, its own first, then those of other ways on, a target reached by several ways on coming with the last of them. Duplicates are kept out while building by a layered set, extended in place by a component that is the sole way into its way on and otherwise laid thinly over it. Nothing is copied, so the index is linear in the elements and transitions, and reading a component's targets walks exactly its own list, however many starts share the same suffix. 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. +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 @@ -95,7 +95,7 @@ Each view has an inverse that takes the view after an edit and returns a `ModelD 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. 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. +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. diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index d11555a..4dd92fa 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -59,23 +59,18 @@ export interface Chain { readonly effect: I["action"] | undefined; } -/** A cell of a list shared by every list that ends with it. */ -interface Cell { - readonly value: T; - readonly next: Cell | 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; } -/** A layer of a set of targets over the set it extends. */ -interface Layer { - readonly extra: Set; - readonly base: Layer | undefined; -} - -/** A component's reachable targets: the head of its list of cells, which holds each target once, and the layered set of the same targets. */ -interface Reached { - readonly head: Cell | undefined; - readonly set: Layer; -} +/** 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 { @@ -129,7 +124,7 @@ export const once = (compute: () => T): (() => T) => { /** * Indexes a model's folded elements so chains can be read off for any first transition. * - * The folded elements are condensed into strongly connected components in one pass. Each component's reachable accepted targets are a list of cells holding each target once, shared with the components after it, and a layered set of the same targets used only while building: a component that is the sole way into its one way on extends that set in place and prepends the targets it adds, any other component lays a thin set of its own targets over its way on's, and a merge prepends onto the last way on's list the targets of the others it lacks. Nothing is copied, so the index stores one reference per cell, set entry and layer, linear in the elements and transitions, and reading a component's targets walks exactly its own list, however many starts read the same suffix. 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. + * 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. */ @@ -194,7 +189,6 @@ export const chainIndex = ( } } - // Each component's accepted targets as a shared structure, built in reverse topological order so every successor's is ready. Nothing is copied: a list grows by prepending cells onto its successor's, and a merge only points at its parts. const observe = folding.observe; const successorsOf: number[][] = components.map(() => []); const predecessors = components.map(() => 0); @@ -215,109 +209,195 @@ export const chainIndex = ( } } } - // Each component's targets as a list of cells free of duplicates, shared with the components after it, and a layered set of the same targets for removing duplicates while building. A layer is extended in place only by the one component that is the sole way into its owner; any other component adds a thin layer of its own over it, so nothing is copied. - const byComponent: Reached[] = []; - const has = ( - set: Layer | undefined, - target: I["element"], - ): boolean => { - for (let layer = set; layer !== undefined; layer = layer.base) { - if (layer.extra.has(target)) { - return true; + // 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 false; + return own; }; - const prepend = ( - targets: readonly I["element"][], - tail: Cell | undefined, - ): Cell | undefined => { - let head = tail; - for (const target of targets.toReversed()) { - observe?.(1); - head = { value: target, next: head }; + /** 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 head; + return found; }; - const leads: boolean[] = []; - for (const [index, members] of components.entries()) { - const own: I["element"][] = []; - const ownSet = new Set(); - for (const member of members) { - for (const { target } of outgoing.get(member) ?? []) { - if ( - !isFolded.has(target) && - folding.accepted(target) && - !ownSet.has(target) - ) { - ownSet.add(target); - own.push(target); - } - } + const piecesFor = new Map[]>(); + const pieces = (component: number): Piece[] => { + const known = piecesFor.get(component); + if (known !== undefined) { + return known; } - const parts = (successorsOf[index] ?? []).flatMap((successor) => { - const part = byComponent[successor]; + const found = piecesOf(component); + piecesFor.set(component, found); - return leads[successor] === true && part !== undefined - ? [{ successor, part }] - : []; - }); - leads[index] = own.length > 0 || parts.length > 0; - const last = parts.at(-1); - if (last === undefined) { - observe?.(own.length); - byComponent[index] = { - head: prepend(own, undefined), - set: { extra: ownSet, base: undefined }, - }; + 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; } - // Targets of the ways on other than the last that the last does not reach, in order; the last way on's list is the shared tail. - const added = new Set(); - const fresh: I["element"][] = []; - for (const { part } of parts.slice(0, -1)) { - for (let cell = part.head; cell !== undefined; cell = cell.next) { - if (!has(last.part.set, cell.value) && !added.has(cell.value)) { - added.add(cell.value); - fresh.push(cell.value); + 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, ...more] = parts; + const onlyArray = + only?.component === undefined ? undefined : arrays.get(only.component); + if (onlyArray !== undefined && more.length === 0) { + // 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 (const piece of (takeOver ? parts.slice(0, -1) : parts).toReversed()) { + if (piece.own !== undefined) { + for (const value of piece.own.toReversed()) { + add(value); } + continue; } - } - const ownFresh = own.filter( - (target) => !has(last.part.set, target) && !added.has(target), - ); - const additions = [...ownFresh, ...fresh]; - const head = prepend(additions, last.part.head); - if (parts.length === 1 && predecessors[last.successor] === 1) { - // The sole way into the last way on: extend its set in place. - for (const target of additions) { - observe?.(1); - last.part.set.extra.add(target); + for (const value of arrays.get(piece.component)?.reversed ?? []) { + add(value); } - byComponent[index] = { head, set: last.part.set }; - } else { - observe?.(additions.length); - byComponent[index] = { - head, - set: { extra: new Set(additions), base: last.part.set }, - }; } - } - /** A component's targets in order: walking its cells, which hold each target once. */ - const targetsOf = (component: number): readonly I["element"][] => { - const listed: I["element"][] = []; + // 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 cell = byComponent[component]?.head; - cell !== undefined; - cell = cell.next + let frame = frames.at(-1); + frame !== undefined; + frame = frames.at(-1) ) { - listed.push(cell.value); + 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 listed; + 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>(); From 30a5395760d7a807da61b0a30ecb583379969dde Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:40:54 +0100 Subject: [PATCH 21/33] fix(projection): act on the steps into each shared end once in the inverses Deleting or editing many edges or state machine transitions whose folded chains share an end walked that end's arriving steps once per edge. Omitted and edited chains are now 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. --- .../graph/projection/src/inverse-flowchart.ts | 52 +++++++++------- .../projection/src/inverse-state-machine.ts | 61 ++++++++++++++++--- 2 files changed, 81 insertions(+), 32 deletions(-) diff --git a/packages/core/graph/projection/src/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts index 6748487..d598864 100644 --- a/packages/core/graph/projection/src/inverse-flowchart.ts +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -1,6 +1,5 @@ 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 { flowchartParts, @@ -172,29 +171,38 @@ const diffFlow = ( return undefined; }; const editedKeys = new Set(edited.edges.map(editedKey)); - /** The transitions arriving at an edge's end: its one transition, or every step from a message into its end. */ - const arriving = (origin: EdgeOrigin): readonly TransitionOf[] => - origin.first.target === origin.target - ? [origin.first] - : parts.index.arriving(origin.target); + // 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; } - // An edge that carries messages shares its earlier steps with every other edge through the same messages, so only the steps arriving at its end are removed, and which of them is a choice: inferred. - for (const transition of arriving(origin)) { + 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: origin.first.target !== origin.target, + inferred: true, }); } } } - const retargeted = new Map(); + // 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); @@ -264,18 +272,18 @@ const diffFlow = ( 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 however many of those edges moved. - for (const tail of arriving(origin)) { - const already = retargeted.get(tail.id); - 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}"`, - ); - } - retargeted.set(tail.id, edge.to); + // 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, diff --git a/packages/core/graph/projection/src/inverse-state-machine.ts b/packages/core/graph/projection/src/inverse-state-machine.ts index ff2abda..24e8b96 100644 --- a/packages/core/graph/projection/src/inverse-state-machine.ts +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -144,18 +144,47 @@ export const diffStateMachine = ( 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; } - for (const tail of arriving(origin)) { - push({ - kind: "removed", - transition: tail.id, - inferred: origin.first.target !== origin.target, - }); + 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); @@ -189,6 +218,12 @@ export const diffStateMachine = ( 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) @@ -210,7 +245,9 @@ export const diffStateMachine = ( inferred: false, }); } - for (const tail of previous.target === edit.target ? [] : tails) { + for (const tail of previous.target === edit.target + ? [] + : acting("target", edit.target)) { push({ kind: "rewired", transition: tail.id, @@ -220,7 +257,9 @@ export const diffStateMachine = ( }); } } - for (const tail of previous.event === edit.event ? [] : tails) { + for (const tail of previous.event === edit.event + ? [] + : acting("trigger", edit.event)) { push({ kind: "changed", field: "trigger", @@ -230,7 +269,9 @@ export const diffStateMachine = ( inferred: chain, }); } - for (const tail of previous.guard === edit.guard ? [] : tails) { + for (const tail of previous.guard === edit.guard + ? [] + : acting("guard", edit.guard)) { push({ kind: "changed", field: "guard", @@ -247,7 +288,7 @@ export const diffStateMachine = ( `The effect of ${JSON.stringify(edit.transitions)} is the action of "${target.id}"; change that activity's action instead`, ); } - for (const tail of tails) { + for (const tail of acting("effect", edit.effect)) { push({ kind: "changed", field: "effect", From c8a35bcc985629c3a17109da334c1e33a5a77a3b Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:41:00 +0100 Subject: [PATCH 22/33] test(projection): count lookups and add serial diamonds and shared-end deletions Work is now counted over set and map lookups as well, so walks over linked structures that test membership as they go are measured. The scaling table gains serial diamonds of messages through every projection and deletion of every edge or transition of sources sharing one end through both inverses, and its shapes move to a test support module. --- .../projection/src/bounded-work.unit.test.ts | 214 +++------------ .../projection/src/test-support/shapes.ts | 252 ++++++++++++++++++ .../graph/projection/src/test-support/work.ts | 5 +- 3 files changed, 295 insertions(+), 176 deletions(-) create mode 100644 packages/core/graph/projection/src/test-support/shapes.ts diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 94a6717..3dd7cbf 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -2,9 +2,24 @@ 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, + authoredRepeatsShape, + combShape, + diamondShape, + empty, + ids, + serialDiamondsShape, + sharedEndShape, + sourcesIntoRunShape, + wideForkShape, + type Shape, +} from "./test-support/shapes.ts"; + import { applyModelDiff, diffFlowchart, + diffStateMachine, handoffs, toFlowchart, toSequence, @@ -40,20 +55,6 @@ const counted = ( return { items: proxy, reads: () => reads }; }; -const ids = (count: number, prefix: string): string[] => - Array.from({ length: count }, (_, index) => `${prefix}${String(index)}`); - -const empty: Model = { - participants: [], - events: [], - actions: [], - artefacts: [], - elements: [], - transitions: [], - fragments: [], - projections: [], -}; - /** Edge reads `diffFlowchart` makes for an edit adding `count` bars joined in a ring. */ const barEdgeReads = (count: number): number => { const bars = ids(count, "bar-"); @@ -500,167 +501,6 @@ describe("nested derived fragments", () => { /** * 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 quadratic in their model, such as a state machine whose every state leads to every other through one folded decision; 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 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, -}); - -interface Shape { - readonly model: Model; - readonly path: readonly string[]; -} - -/** Messages in a line, each with its own exit, entered from one activity. */ -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. */ -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. */ -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. */ -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. */ -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", - ], - }; -}; - const withEntity = ({ model, path, @@ -693,6 +533,7 @@ const shapes: readonly (readonly [string, (count: number) => Shape])[] = [ ], ["repeated steps under many authored fragments", authoredRepeatsShape], ["a wide fork", wideForkShape], + ["serial diamonds of messages", serialDiamondsShape], ["branches sharing one tail", (count) => withEntity(sharedTail(count))], ["a decision with many ways round", (count) => withEntity(manyLoops(count))], ]; @@ -784,6 +625,29 @@ const edits: readonly (readonly [string, (count: number) => () => unknown])[] = return () => diffFlowchart(empty, edited); }, ], + [ + "diffFlowchart deleting every edge of sources whose messages share one end", + (count) => { + const { model } = sharedEndShape(count); + const { nodes } = toFlowchart(model); + + return () => diffFlowchart(model, { nodes, edges: [] }); + }, + ], + [ + "diffStateMachine deleting every transition of sources whose messages share one end", + (count) => { + const { model } = sharedEndShape(count); + const { states } = toStateMachine(model, "order"); + + return () => + diffStateMachine(model, { + subject: "order", + states, + transitions: [], + }); + }, + ], [ "applyModelDiff renaming every activity", (count) => { 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..f16cd11 --- /dev/null +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -0,0 +1,252 @@ +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: [], + }; +}; diff --git a/packages/core/graph/projection/src/test-support/work.ts b/packages/core/graph/projection/src/test-support/work.ts index 455c631..1fcd978 100644 --- a/packages/core/graph/projection/src/test-support/work.ts +++ b/packages/core/graph/projection/src/test-support/work.ts @@ -1,5 +1,5 @@ /** - * Counts the work a call does through the collection operations every projection is built from: each `push`, `Set.add` and `Map.set` counts one, 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. + * Counts the work a call does through the collection operations every projection is built from: 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. */ @@ -53,7 +53,10 @@ export const workOf = (run: () => unknown): number => { } wrap(Array.prototype, "push", 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 { From e294f71eb7f6f7cde021a2b8248f4a356739a356 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 22:43:09 +0100 Subject: [PATCH 23/33] test(projection): size the scaling table so it stays quick under load A shape whose state machine output is itself quadratic took longer than the test timeout to build at the shared size with every collection call counted. Projected shapes now start smaller, still large enough that quadratic work doubles per item, while the edit rows keep the larger size, which a rescan per edited item needs to overtake the fixed cost of re-projecting the model. --- .../core/graph/projection/src/bounded-work.unit.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 3dd7cbf..9ea5aa1 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -678,11 +678,13 @@ const edits: readonly (readonly [string, (count: number) => () => unknown])[] = ]; 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) => { - const small = build(size); - const large = build(doubled * size); + const small = build(tableSize); + const large = build(doubled * tableSize); it.each(entries)("through %s", (_entry, enter) => { expect(workPerItem(large, enter)).toBeLessThan( @@ -692,6 +694,7 @@ describe("work at most doubles when a reported shape doubles", () => { }); 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); From 9015f08da9dc7cc7a79f7831f3a2c30f8f87f79e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 23:52:18 +0100 Subject: [PATCH 24/33] fix(projection): read a target's arriving steps without copying them per chain Every chain into a target destructured the list of folded steps arriving at it into its first item and a copy of the rest, so many paths converging on one end copied that list once per path. Chains now read its length and its one item by index. The other places that took a list apart the same way read it by index too, though each ran once per element or component. --- packages/core/graph/projection/src/chains.ts | 30 +++++++++++-------- .../graph/projection/src/inverse-flowchart.ts | 4 +-- .../projection/src/inverse-state-machine.ts | 4 +-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 4dd92fa..1d04954 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -293,10 +293,10 @@ export const chainIndex = ( let generation = 0; const build = (component: number): Targets => { const parts = pieces(component); - const [only, ...more] = parts; + const only = parts.length === 1 ? parts[0] : undefined; const onlyArray = only?.component === undefined ? undefined : arrays.get(only.component); - if (onlyArray !== undefined && more.length === 0) { + if (onlyArray !== undefined) { // Nothing of its own to add to the one array it reads: share it. onlyArray.pinned ||= pinnedFor(component); @@ -332,7 +332,15 @@ export const chainIndex = ( members.add(value); } }; - for (const piece of (takeOver ? parts.slice(0, -1) : parts).toReversed()) { + 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); @@ -403,14 +411,9 @@ export const chainIndex = ( const parentLink = new Map>(); const children = new Map(); for (const id of foldedIds) { - const into = foldedIn.get(id) ?? []; - const [link, ...rest] = into; - if ( - link !== undefined && - rest.length === 0 && - !cyclic.has(id) && - !cyclic.has(link.source) - ) { + 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); } @@ -566,12 +569,13 @@ export const chainIndex = ( const level = depth.get(start); return reached.map((target): Chain => { - const [last, ...more] = arrivingIn.get(target) ?? []; + // 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 && - more.length === 0 && level !== undefined && above(start, source); const lists = once(() => union(first, target)); diff --git a/packages/core/graph/projection/src/inverse-flowchart.ts b/packages/core/graph/projection/src/inverse-flowchart.ts index d598864..edcabb8 100644 --- a/packages/core/graph/projection/src/inverse-flowchart.ts +++ b/packages/core/graph/projection/src/inverse-flowchart.ts @@ -206,11 +206,11 @@ const diffFlow = ( for (const edge of edited.edges) { const key = editedKey(edge); const before = key === undefined ? undefined : originals.get(key); - const [first, ...rest] = edge.transitions; + const [first] = edge.transitions; if (before === undefined) { if ( first === undefined || - rest.length > 0 || + edge.transitions.length > 1 || transitions.has(first) || edge.through !== undefined ) { diff --git a/packages/core/graph/projection/src/inverse-state-machine.ts b/packages/core/graph/projection/src/inverse-state-machine.ts index 24e8b96..1dcea80 100644 --- a/packages/core/graph/projection/src/inverse-state-machine.ts +++ b/packages/core/graph/projection/src/inverse-state-machine.ts @@ -188,11 +188,11 @@ export const diffStateMachine = ( for (const edit of edited.transitions) { const key = editedKey(edit); const before = key === undefined ? undefined : originals.get(key); - const [first, ...rest] = edit.transitions; + const [first] = edit.transitions; if (before === undefined) { if ( first === undefined || - rest.length > 0 || + edit.transitions.length > 1 || transitions.has(first) || edit.through !== undefined ) { From 62da1209d42c74a1f569f00fdf6b0bfa1c9932e2 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 23:52:23 +0100 Subject: [PATCH 25/33] fix(projection): check whether a nested fragment fits in constant or log time Placing a fragment inside an authored operand counted the operand's positions across all of its elements for each nested fragment, so one fragment over a long path with a fragment inside it per step did work quadratic in the path. A fragment with one operand holds its whole span; an operand held by elements keeps a set of them, against which an authored fragment inside it is checked by its own elements; any other span is checked by binary search or position by position, whichever is shorter. --- packages/core/graph/projection/README.md | 2 +- .../core/graph/projection/src/fragments.ts | 64 +++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 88929c5..0d4a8a1 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -62,7 +62,7 @@ Fragments are derived from the model's structure as the scenario passes its deci 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. 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. +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 diff --git a/packages/core/graph/projection/src/fragments.ts b/packages/core/graph/projection/src/fragments.ts index 784cba1..0f4940e 100644 --- a/packages/core/graph/projection/src/fragments.ts +++ b/packages/core/graph/projection/src/fragments.ts @@ -463,6 +463,8 @@ interface Placed { 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; @@ -539,6 +541,56 @@ const holdsAll = (held: Held, first: number, last: number): boolean => { : 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. */ @@ -701,6 +753,8 @@ export const wrapFragments = ( ); 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); @@ -715,8 +769,7 @@ export const wrapFragments = ( let parent: Placed["parent"]; if (outer !== undefined) { const slot = outer.slotOf(first); - const operand = outer.candidate.operands[slot]; - if (operand === undefined || !holdsAll(operand, first, last)) { + if (!fits(outer, slot, candidate, { first, last })) { continue; } parent = { placed: outer, slot }; @@ -725,11 +778,8 @@ export const wrapFragments = ( candidate, first, last, - slotOf: slotIndex( - candidate.operands, - (position) => entries[position]?.target, - observe, - ), + slotOf: slotIndex(candidate.operands, elementAt, observe), + elementAt, parent, order, }; From 311d958c3f29512cb49c6850968256fd90b6b813 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sat, 10 Oct 2026 23:52:27 +0100 Subject: [PATCH 26/33] test(projection): count iterator steps and add converging paths and nested authored fragments Work is now also counted at each step of an array's iterator, so spreads, destructuring and loops over lists are measured. The scaling table gains many paths converging on one end through every projection, at the size where its quadratic part shows, and an authored fragment over the path with one inside it per step through the sequence diagram. --- .../projection/src/bounded-work.unit.test.ts | 19 ++++++++++--- .../projection/src/test-support/shapes.ts | 27 +++++++++++++++++++ .../graph/projection/src/test-support/work.ts | 8 +++++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 9ea5aa1..94e4c1d 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -4,6 +4,7 @@ import { sequenceObserved } from "./sequence.ts"; import { workOf } from "./test-support/work.ts"; import { actors, + authoredNestingShape, authoredRepeatsShape, combShape, diamondShape, @@ -512,7 +513,11 @@ const withEntity = ({ path, }); -const shapes: readonly (readonly [string, (count: number) => Shape])[] = [ +/** 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))], @@ -534,6 +539,12 @@ const shapes: readonly (readonly [string, (count: number) => Shape])[] = [ ["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], + [ + "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))], ]; @@ -682,9 +693,9 @@ const doubled = 2; const tableSize = 100; describe("work at most doubles when a reported shape doubles", () => { - describe.each(shapes)("%s", (_shape, build) => { - const small = build(tableSize); - const large = build(doubled * tableSize); + 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( diff --git a/packages/core/graph/projection/src/test-support/shapes.ts b/packages/core/graph/projection/src/test-support/shapes.ts index f16cd11..390faff 100644 --- a/packages/core/graph/projection/src/test-support/shapes.ts +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -250,3 +250,30 @@ export const sharedEndShape = (count: number): Shape => { 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}`), + }; +}; diff --git a/packages/core/graph/projection/src/test-support/work.ts b/packages/core/graph/projection/src/test-support/work.ts index 1fcd978..fc52cc2 100644 --- a/packages/core/graph/projection/src/test-support/work.ts +++ b/packages/core/graph/projection/src/test-support/work.ts @@ -1,5 +1,5 @@ /** - * Counts the work a call does through the collection operations every projection is built from: 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. + * 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. */ @@ -52,6 +52,12 @@ export const workOf = (run: () => unknown): number => { 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); From 32e17d52ddfaf13a38421e6252e749d64df19997 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 00:12:45 +0100 Subject: [PATCH 27/33] fix(projection): share the walk behind merged chains across their targets Each merged chain walked everything its start reaches and filtered it by what leads to its own target, so reading every edge of a start with many common targets walked the same folded branches again per target. The walk from a start is now done once, numbering what it meets and indexing transitions by where they arrive; a chain's union is gathered by walking back from its target over that index and put in walk order, so it costs the size of the union. --- packages/core/graph/projection/README.md | 2 +- packages/core/graph/projection/src/chains.ts | 71 +++++++++++++------- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 0d4a8a1..5d692f3 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -29,7 +29,7 @@ A `message` element is read by every projection. A sequence step arriving at one 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. +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. Where routes merge, the walk over what a start reaches is done once for that start 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. diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 1d04954..3c0dc27 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -496,19 +496,23 @@ export const chainIndex = ( ); }; - /** The union of every route from `start` to `target`, by a depth-first walk from `start` and a walk back from `target` over what it met. */ - const union = ( - first: TransitionOf, - target: I["element"], - ): { - readonly transitions: readonly TransitionOf[]; - readonly through: readonly I["element"][]; - } => { - const met: TransitionOf[] = [first]; - const entered: I["element"][] = [first.target]; - const seen = new Set([first.target]); + /** What a depth-first walk from a folded element meets: 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 element walked from, however many chains start there or end at its targets. */ + interface Walk { + readonly order: ReadonlyMap, number>; + readonly entered: ReadonlyMap; + readonly into: ReadonlyMap[]>; + } + const walks = new Map(); + const walkFrom = (start: I["element"]): Walk => { + const known = walks.get(start); + if (known !== undefined) { + return known; + } + const order = new Map, number>(); + const entered = new Map([[start, 0]]); + const into = new Map[]>(); const stack: { readonly id: I["element"]; next: number }[] = [ - { id: first.target, next: 0 }, + { id: start, next: 0 }, ]; for (let top = stack.at(-1); top !== undefined; top = stack.at(-1)) { const transition = (outgoing.get(top.id) ?? [])[top.next]; @@ -517,31 +521,50 @@ export const chainIndex = ( continue; } top.next += 1; - met.push(transition); - if (isFolded.has(transition.target) && !seen.has(transition.target)) { - seen.add(transition.target); - entered.push(transition.target); + 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 into = new Map[]>(); - for (const transition of met) { - append(into, transition.target, transition); - } + const walk = { order, entered, into }; + walks.set(start, 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 into.get(at) ?? []) { - if (seen.has(transition.source) && !leading.has(transition.source)) { + 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: met.filter((transition) => leading.has(transition.target)), - through: entered.filter((id) => leading.has(id)), + 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)), }; }; From 8c42466eb36235a465c6ed0fe3c64dbbd9365586 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 00:12:50 +0100 Subject: [PATCH 28/33] test(projection): add merged chains over two branches and read every edge The scaling table gains a flowchart entry that reads every edge's lists and label, counting what it reads as output, a shape of two folded branches leading to many common ends, and an unchanged flowchart of those merged edges diffed against its model. --- .../projection/src/bounded-work.unit.test.ts | 28 +++++++++++++++++++ .../projection/src/test-support/shapes.ts | 27 ++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index 94e4c1d..bc295ad 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -13,6 +13,7 @@ import { serialDiamondsShape, sharedEndShape, sourcesIntoRunShape, + twoBranchesShape, wideForkShape, type Shape, } from "./test-support/shapes.ts"; @@ -541,6 +542,8 @@ const shapes: readonly ( ["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], [ "an authored fragment over the path with one inside it per step", authoredNestingShape, @@ -559,6 +562,22 @@ const entries: readonly (readonly [string, (shape: Shape) => number])[] = [ 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 }) => { @@ -659,6 +678,15 @@ const edits: readonly (readonly [string, (count: number) => () => unknown])[] = }); }, ], + [ + "diffFlowchart reading an unchanged flowchart of merged edges over two branches", + (count) => { + const { model } = twoBranchesShape(count); + const flow = toFlowchart(model); + + return () => diffFlowchart(model, flow); + }, + ], [ "applyModelDiff renaming every activity", (count) => { diff --git a/packages/core/graph/projection/src/test-support/shapes.ts b/packages/core/graph/projection/src/test-support/shapes.ts index 390faff..d009d37 100644 --- a/packages/core/graph/projection/src/test-support/shapes.ts +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -277,3 +277,30 @@ export const authoredNestingShape = (count: number): Shape => { 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: [], + }; +}; From 5ab2c4c054496559fc7d8d091c03ed7e46019b22 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 00:25:22 +0100 Subject: [PATCH 29/33] fix(projection): walk only folded elements that lead to a drawn element The walk behind merged chains entered everything a start reached, so many short chains sharing a run of messages that leads nowhere each walked and kept that run. The folded elements that lead to some accepted element are now found once, by walking back from the accepted elements, and every walk and the carried messages enter only those. Walks are shared per component, from its first member in model order, since every member of a component reaches the same elements. --- packages/core/graph/projection/src/chains.ts | 78 +++++++++++++++----- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 3c0dc27..55a871a 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -124,7 +124,7 @@ export const once = (compute: () => T): (() => T) => { /** * Indexes a model's folded elements so chains can be read off for any first transition. * - * 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. + * 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. */ @@ -160,6 +160,32 @@ export const chainIndex = ( ); } + // 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) => @@ -496,18 +522,37 @@ export const chainIndex = ( ); }; - /** What a depth-first walk from a folded element meets: 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 element walked from, however many chains start there or end at its targets. */ + /** 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 walks = new Map(); - const walkFrom = (start: I["element"]): Walk => { - const known = walks.get(start); + 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[]>(); @@ -521,6 +566,9 @@ export const chainIndex = ( 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)) { @@ -529,7 +577,9 @@ export const chainIndex = ( } } const walk = { order, entered, into }; - walks.set(start, walk); + if (component !== undefined) { + walks.set(component, walk); + } return walk; }; @@ -690,31 +740,21 @@ export const chainIndex = ( const reached = new Set(); const stack: I["element"][] = []; for (const first of firsts) { - if (isFolded.has(first.target) && !reached.has(first.target)) { + 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 ( - isFolded.has(transition.target) && - !reached.has(transition.target) - ) { + if (live.has(transition.target) && !reached.has(transition.target)) { reached.add(transition.target); stack.push(transition.target); } } } - const leading = new Set(); - for (const id of reached) { - const component = componentOf.get(id); - if (component !== undefined && leads[component] === true) { - leading.add(id); - } - } - return leading; + return reached; }; return { From abaa995954d563b73e34fc8e92eb4e6bf22514dc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 00:25:26 +0100 Subject: [PATCH 30/33] test(projection): add shared dead runs and measure edits per item The scaling table gains diamonds sharing a run that leads nowhere, and one half of which leads somewhere, through every projection and an unchanged flowchart diff. Edit rows are measured per item of their input, the model and the edited view as read, as the projection rows are. --- .../projection/src/bounded-work.unit.test.ts | 203 ++++++++++++------ .../projection/src/test-support/shapes.ts | 46 ++++ 2 files changed, 178 insertions(+), 71 deletions(-) diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index bc295ad..ae5188f 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -12,6 +12,7 @@ import { ids, serialDiamondsShape, sharedEndShape, + sharedSuffixShape, sourcesIntoRunShape, twoBranchesShape, wideForkShape, @@ -544,6 +545,13 @@ const shapes: readonly ( ["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], + // 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, @@ -633,88 +641,139 @@ const workPerItem = (shape: Shape, enter: (shape: Shape) => number): number => { }; /** The edit-list shapes, measured through the inverses that read them. */ -const edits: readonly (readonly [string, (count: number) => () => unknown])[] = - [ - [ - "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 () => diffFlowchart(empty, edited); - }, - ], - [ - "diffFlowchart deleting every edge of sources whose messages share one end", - (count) => { - const { model } = sharedEndShape(count); - const { nodes } = toFlowchart(model); +/** 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, + ); - return () => diffFlowchart(model, { nodes, edges: [] }); - }, - ], - [ - "diffStateMachine deleting every transition of sources whose messages share one end", - (count) => { - const { model } = sharedEndShape(count); - const { states } = toStateMachine(model, "order"); +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 () => + return { + run: () => diffStateMachine(model, { subject: "order", states, transitions: [], - }); - }, - ], - [ - "diffFlowchart reading an unchanged flowchart of merged edges over two branches", + }), + 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 } = twoBranchesShape(count); + const { model } = sharedSuffixShape(livePart)(count); const flow = toFlowchart(model); - return () => diffFlowchart(model, flow); - }, - ], - [ - "applyModelDiff renaming every activity", - (count) => { - const activities = ids(count, "a-"); - const model: Model = { - ...empty, - elements: activities.map((id) => ({ - kind: "activity", - id, - label: id, - })), + return { + run: () => diffFlowchart(model, flow), + input: modelSize(model) + viewSize(flow), }; - const diff: ModelDiff = { - elements: activities.map((id) => ({ - kind: "renamed", - element: id, - from: id, - to: `${id}!`, - inferred: false, - })), - transitions: [], - events: [], - }; - - return () => applyModelDiff(model, diff); }, ], - ]; + ), + [ + "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. */ @@ -737,6 +796,8 @@ describe("work at most doubles when a reported shape doubles", () => { const small = prepare(size); const large = prepare(doubled * size); - expect(workOf(large)).toBeLessThan(growthLimit * workOf(small)); + expect(workOf(large.run) / large.input).toBeLessThan( + (growthLimit / doubled) * (workOf(small.run) / small.input), + ); }); }); diff --git a/packages/core/graph/projection/src/test-support/shapes.ts b/packages/core/graph/projection/src/test-support/shapes.ts index d009d37..db54abb 100644 --- a/packages/core/graph/projection/src/test-support/shapes.ts +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -304,3 +304,49 @@ export const twoBranchesShape = (count: number): Shape => { 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: [], + }; + }; From 8cad527c6b6a27d3502c902ea250eec44693f449 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 00:25:31 +0100 Subject: [PATCH 31/33] docs(projection): state the cost model of the projections and inverses --- packages/core/graph/projection/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index 5d692f3..bde41c3 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -29,7 +29,7 @@ A `message` element is read by every projection. A sequence step arriving at one 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. Where routes merge, the walk over what a start reaches is done once for that start and indexed by where each transition arrives, so reading one chain's union walks back over that union alone. +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. @@ -113,6 +113,10 @@ const next = applyModelDiff( ); ``` +## Cost + +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; a state machine whose states all lead to one another through one folded decision has quadratically many transitions. 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 From 913fdd393e4572d43df1081f73ce1f14c2e96d09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 01:13:54 +0100 Subject: [PATCH 32/33] fix(projection): never fold an element when folding would multiply edges Folding an element draws one chain per way in and drawn element it leads to, so many sources through one message, or a run of messages, to many ends drew every source to every end: quadratic output from linear input. An element is now folded only when the distinct entering transitions reaching it times the distinct drawn elements it reaches, through folded elements, is no more than their sum. The counts are capped sets carried once each way over the condensed folded graph. An element left unfolded is reported, with the transitions touching it, as an unrepresented element by the flowchart, swimlane and state machine; hand-offs and the inverses follow the same index. --- packages/core/graph/projection/README.md | 28 +++--- packages/core/graph/projection/src/chains.ts | 93 ++++++++++++++++++- .../core/graph/projection/src/flowchart.ts | 29 ++++-- packages/core/graph/projection/src/loss.ts | 9 +- .../graph/projection/src/loss.unit.test.ts | 25 +++++ .../graph/projection/src/state-machine.ts | 13 ++- .../core/graph/projection/src/swimlane.ts | 6 +- .../projection/src/test-support/shapes.ts | 37 ++++++++ 8 files changed, 211 insertions(+), 29 deletions(-) diff --git a/packages/core/graph/projection/README.md b/packages/core/graph/projection/README.md index bde41c3..e59567f 100644 --- a/packages/core/graph/projection/README.md +++ b/packages/core/graph/projection/README.md @@ -25,7 +25,7 @@ A transition between two elements with different actors is a hand-off, never sto ## 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. +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. @@ -68,17 +68,17 @@ An authored fragment's operand holds the messages arriving at an element of its 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 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`, 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. @@ -115,7 +115,9 @@ const next = applyModelDiff( ## Cost -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; a state machine whose states all lead to one another through one folded decision has quadratically many transitions. 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. +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 diff --git a/packages/core/graph/projection/src/chains.ts b/packages/core/graph/projection/src/chains.ts index 55a871a..36145bc 100644 --- a/packages/core/graph/projection/src/chains.ts +++ b/packages/core/graph/projection/src/chains.ts @@ -94,6 +94,8 @@ export interface ChainIndex { 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`. */ @@ -121,9 +123,91 @@ export const once = (compute: () => T): (() => T) => { }; }; +/** 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. @@ -133,12 +217,16 @@ export const chainIndex = ( folding: Folding, ): ChainIndex => { const outgoing = outgoingMap(model); - const isFolded = new Set(); + const foldable = new Set(); for (const element of model.elements) { if (folding.folded(element.id)) { - isFolded.add(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); @@ -761,5 +849,6 @@ export const chainIndex = ( from, arriving: (element) => arrivingIn.get(element) ?? [], carried, + unfolded, }; }; diff --git a/packages/core/graph/projection/src/flowchart.ts b/packages/core/graph/projection/src/flowchart.ts index 0ba752d..733e054 100644 --- a/packages/core/graph/projection/src/flowchart.ts +++ b/packages/core/graph/projection/src/flowchart.ts @@ -64,24 +64,35 @@ 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 carried - The message elements some edge carries, which are shown. + * @param drawn - What the projection drew. */ export const flowchartLoss = ( model: Model, notation: "flowchart" | "swimlane", - nodes: readonly FlowNode[], - carried: ReadonlySet, + drawn: Drawn, ): readonly Loss[] => [ - ...commonLoss(model, notation, { fragments: new Set(), messages: carried }), + ...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, ), ]; @@ -193,7 +204,11 @@ export const flowchartParts = ( flowchart: { nodes, edges, - loss: flowchartLoss(model, "flowchart", nodes, carried), + loss: flowchartLoss(model, "flowchart", { + nodes, + carried, + unfolded: index.unfolded, + }), }, origins, index, diff --git a/packages/core/graph/projection/src/loss.ts b/packages/core/graph/projection/src/loss.ts index 5892b01..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 { @@ -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({ 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/state-machine.ts b/packages/core/graph/projection/src/state-machine.ts index 7afdf23..eebc2bc 100644 --- a/packages/core/graph/projection/src/state-machine.ts +++ b/packages/core/graph/projection/src/state-machine.ts @@ -194,16 +194,23 @@ export const stateMachineParts = ( const loss: Loss[] = [ ...commonLoss(model, "stateMachine", { fragments: new Set(), - messages: carried, + 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({ diff --git a/packages/core/graph/projection/src/swimlane.ts b/packages/core/graph/projection/src/swimlane.ts index 61839d2..9c20edc 100644 --- a/packages/core/graph/projection/src/swimlane.ts +++ b/packages/core/graph/projection/src/swimlane.ts @@ -65,6 +65,10 @@ export const toSwimlane = (model: Model): Swimlane => { lanes, unassigned, handoffs: handoffs(model), - loss: flowchartLoss(model, "swimlane", flow.nodes, parts.carried), + loss: flowchartLoss(model, "swimlane", { + nodes: flow.nodes, + carried: parts.carried, + unfolded: parts.index.unfolded, + }), }; }; diff --git a/packages/core/graph/projection/src/test-support/shapes.ts b/packages/core/graph/projection/src/test-support/shapes.ts index db54abb..5fd4be9 100644 --- a/packages/core/graph/projection/src/test-support/shapes.ts +++ b/packages/core/graph/projection/src/test-support/shapes.ts @@ -350,3 +350,40 @@ export const sharedSuffixShape = 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: [], + }; + }; From ff542343ba58cdcf534c2c7d658e7d92e850c3b8 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 11 Oct 2026 01:13:59 +0100 Subject: [PATCH 33/33] test(projection): check folding never multiplies edges The scaling table gains many sources through one message, and through two, to many ends, and a test that the flowchart, hand-offs and state machine draw no more edges than the model has transitions for those shapes as they double. --- .../projection/src/bounded-work.unit.test.ts | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/core/graph/projection/src/bounded-work.unit.test.ts b/packages/core/graph/projection/src/bounded-work.unit.test.ts index ae5188f..cc201a1 100644 --- a/packages/core/graph/projection/src/bounded-work.unit.test.ts +++ b/packages/core/graph/projection/src/bounded-work.unit.test.ts @@ -12,6 +12,7 @@ import { ids, serialDiamondsShape, sharedEndShape, + fanThroughShape, sharedSuffixShape, sourcesIntoRunShape, twoBranchesShape, @@ -502,7 +503,7 @@ describe("nested derived fragments", () => { }); /** - * 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 quadratic in their model, such as a state machine whose every state leads to every other through one folded decision; 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. + * 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, @@ -545,6 +546,8 @@ const shapes: readonly ( ["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], [ @@ -801,3 +804,21 @@ describe("work at most doubles when a reported shape doubles", () => { ); }); }); + +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); + } + }, + ); +});