Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8ab73bf
feat(projection): derive sequence fragments and read message elements
Mearman Oct 10, 2026
43d5550
feat(projection): turn an edited view back into a diff against the model
Mearman Oct 10, 2026
aa2b71b
docs(projection): document message elements, derived fragments and vi…
Mearman Oct 10, 2026
37a8d40
refactor(projection): keep the derived operand type internal
Mearman Oct 10, 2026
b7979cb
fix(projection): collapse routes through folded elements instead of e…
Mearman Oct 10, 2026
fcf3b74
feat(dag): export stronglyConnectedComponents
Mearman Oct 10, 2026
443b7bb
fix(projection): derive loop fragments from strongly connected compon…
Mearman Oct 10, 2026
f272eda
fix(projection): match repeated sequence steps without copying
Mearman Oct 10, 2026
4ff9b40
fix(projection): index edits and changes in one pass before using them
Mearman Oct 10, 2026
c722e00
feat(dag): find immediate dominators
Mearman Oct 10, 2026
0cd428b
fix(projection): derive sequence fragments along the path in linear p…
Mearman Oct 10, 2026
5decfe7
refactor(projection): settle each branching element's shape once
Mearman Oct 10, 2026
c98450d
fix(projection): index folded elements once and build chain lists whe…
Mearman Oct 10, 2026
b1b1329
fix(projection): hold derived spans by their ends and place fragments…
Mearman Oct 10, 2026
38f4aa8
docs: mention the projection inverses in the package table
Mearman Oct 10, 2026
a725ad3
fix(projection): hold each component's targets as a shared structure
Mearman Oct 10, 2026
2e2997e
fix(projection): share each component's deduplicated targets with eve…
Mearman Oct 10, 2026
3b9ca01
fix(projection): share each element's message positions across author…
Mearman Oct 10, 2026
6912035
test(projection): check every reported quadratic shape scales linearly
Mearman Oct 10, 2026
08b9cde
fix(projection): build flat target arrays only where they are read
Mearman Oct 10, 2026
30a5395
fix(projection): act on the steps into each shared end once in the in…
Mearman Oct 10, 2026
c8a35bc
test(projection): count lookups and add serial diamonds and shared-en…
Mearman Oct 10, 2026
e294f71
test(projection): size the scaling table so it stays quick under load
Mearman Oct 10, 2026
9015f08
fix(projection): read a target's arriving steps without copying them …
Mearman Oct 10, 2026
62da120
fix(projection): check whether a nested fragment fits in constant or …
Mearman Oct 10, 2026
311d958
test(projection): count iterator steps and add converging paths and n…
Mearman Oct 10, 2026
32e17d5
fix(projection): share the walk behind merged chains across their tar…
Mearman Oct 10, 2026
8c42466
test(projection): add merged chains over two branches and read every …
Mearman Oct 10, 2026
5ab2c4c
fix(projection): walk only folded elements that lead to a drawn element
Mearman Oct 10, 2026
abaa995
test(projection): add shared dead runs and measure edits per item
Mearman Oct 10, 2026
8cad527
docs(projection): state the cost model of the projections and inverses
Mearman Oct 10, 2026
913fdd3
fix(projection): never fold an element when folding would multiply edges
Mearman Oct 11, 2026
ff54234
test(projection): check folding never multiplies edges
Mearman Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
20 changes: 11 additions & 9 deletions packages/core/graph/dag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,17 @@ Nothing is absorbed silently:

## API

| Function | Returns | With a cycle |
| --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `buildDag(nodes, needs)` | An immutable `Dag`: nodes, needs, dependents, unknown references | Builds normally. |
| `topologicalLayers(dag)` | `levels`, `layers`, `unlayered` | Layers the acyclic part; nodes on a cycle or needing one are returned in `unlayered`. |
| `ancestorsOf(dag, id)` | Transitive needs, in node order | Includes `id` itself when it is on a cycle. |
| `descendantsOf(dag, id)` | Transitive dependents, in node order | Includes `id` itself when it is on a cycle. |
| `findCycles(dag)` | One closed path per cyclic strongly connected component | Not applicable; this is the report. |
| `transitiveReduction(dag)` | A `Dag` without redundant edges | Throws `CycleError`: with a cycle the reduction is not unique. |
| `criticalPath(dag, weight)` | `{ path, weight }`, the heaviest chain | Throws `CycleError`: a chain through a cycle has no greatest weight. |
| Function | Returns | With a cycle |
| ---------------------------------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `buildDag(nodes, needs)` | An immutable `Dag`: nodes, needs, dependents, unknown references | Builds normally. |
| `topologicalLayers(dag)` | `levels`, `layers`, `unlayered` | Layers the acyclic part; nodes on a cycle or needing one are returned in `unlayered`. |
| `ancestorsOf(dag, id)` | Transitive needs, in node order | Includes `id` itself when it is on a cycle. |
| `descendantsOf(dag, id)` | Transitive dependents, in node order | Includes `id` itself when it is on a cycle. |
| `findCycles(dag)` | One closed path per cyclic strongly connected component | Not applicable; this is the report. |
| `stronglyConnectedComponents(dag)` | Every strongly connected component, each node in exactly one, a component before those that need it | Not applicable; a cycle is a component of several nodes or of one node needing itself. |
| `immediateDominators(dag, root)` | Each node reachable from `root` along `needs` mapped to its immediate dominator | Allowed; dominance is defined on any graph. |
| `transitiveReduction(dag)` | A `Dag` without redundant edges | Throws `CycleError`: with a cycle the reduction is not unique. |
| `criticalPath(dag, weight)` | `{ path, weight }`, the heaviest chain | Throws `CycleError`: a chain through a cycle has no greatest weight. |

`ancestorsOf` and `descendantsOf` throw `UnknownNodeError` for an id that is not a node. `criticalPath` throws `RangeError` for a negative or non-finite weight.

Expand Down
8 changes: 6 additions & 2 deletions packages/core/graph/dag/src/cycles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ interface TarjanFrame<Id extends NodeId> {
}

/**
* 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<Id extends NodeId>(
export function stronglyConnectedComponents<Id extends NodeId>(
dag: Dag<Id>,
): readonly (readonly Id[])[] {
const index = new Map<Id, number>();
Expand Down
37 changes: 36 additions & 1 deletion packages/core/graph/dag/src/cycles.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
});
});
114 changes: 114 additions & 0 deletions packages/core/graph/dag/src/dominators.ts
Original file line number Diff line number Diff line change
@@ -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<Id extends NodeId>(
dag: Dag<Id>,
root: Id,
): ReadonlyMap<Id, Id> {
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<Id, number>();
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<Id, Id>();
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;
}
179 changes: 179 additions & 0 deletions packages/core/graph/dag/src/dominators.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
root: string,
): ReadonlyMap<string, string> => {
const reachable = new Set<string>([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<string, string[]>(
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<string, Set<string>>(
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<string>([
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<string, string>();
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<string> => {
const next = sequence(seed);
const names = Array.from(
{ length: randomNodes },
(_, index) => `n${String(index)}`,
);
const table: Record<string, string[]> = 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)}`,
);
});
});
Loading
Loading