Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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-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. |

## Conventions

Expand Down
1 change: 1 addition & 0 deletions packages/core/graph/domain/AGENTS.md
1 change: 1 addition & 0 deletions packages/core/graph/domain/CLAUDE.md
102 changes: 102 additions & 0 deletions packages/core/graph/domain/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# @exadev/process-graph-domain

Reasoning over the process model from `@exadev/process-graph-schema` and a run overlay: which work is ready, what blocks what, what a decision's branches do, which cycles are legal, and what finishing something changes. Pure functions over `Model<I>` and `Overlay<I>`, built on `@exadev/process-graph-dag`. Run state is an input, never part of the model. `src/` imports nothing Node-only.

```ts
import {
readySet,
blockedBy,
deriveStatuses,
} from "@exadev/process-graph-domain";

// The injected source of facts: here, one boolean the model's decision reads.
const facts = (key) =>
key === "approved"
? { found: true, value: { kind: "boolean", value: true } }
: { found: false };
const overlay = { triage: "done" };

readySet(model, overlay, facts); // ["accept"]
blockedBy(model, overlay, facts, "close"); // ["accept"]
deriveStatuses(model, overlay, facts); // Map { "triage" => "done", ... }
```

## Functions

| Function | Result |
| --------------------------------------------- | --------------------------------------------------------------------------------------- |
| `controlDag(model)` | The `graph-dag` `Dag` over element ids, with loop edges removed. |
| `loopTransitions(model)` | The transitions that close a legal decision loop. |
| `illegalCycles(model)` | The cycles of the control graph, each with its path. |
| `evaluateBranches(model, overlay, facts)` | Each transition's `taken`, `not-taken` or `indeterminate`. |
| `deriveStatuses(model, overlay, facts)` | The `Status` of every element. |
| `readySet(model, overlay, facts)` | The elements that are `ready`. |
| `blockedBy(model, overlay, facts, id)` | The open leaves an element is waiting on. |
| `impact(model, overlay, facts, id)` | The live elements that transitively depend on `id`. |
| `whatIfDone(model, overlay, facts, id)` | The elements that become ready and every status change if `id` were done. |
| `criticalPath(model, overlay, facts, weight)` | The heaviest chain through the work still ahead. |
| `unmetOutputs(model, overlay, facts)` | Each deliverable not yet produced, with the elements and missing inputs it still needs. |

Every function that derives status throws `CycleError` (from `graph-dag`, carrying the cycles) when `illegalCycles` is not empty, `UnknownNodeError` when a transition names an element the model does not declare, and `UnsupportedConditionError` for a condition the facts port cannot serve.

## The control graph

Nodes are element ids. A node needs the sources of its incoming transitions and, for an activity, the producer of every artefact in `consumes`.

A **loop edge** is a transition leaving a `decision` element whose target can reach its source (or is its source), so that it closes a cycle through the decision. Reachability is over the whole graph, transitions and artefact dependencies alike. A loop edge is excluded from `needs`: it is a way back to earlier work, never a prerequisite of it, so readiness is computed on the loop-free graph. An element whose only incoming transitions are loop edges has no needs, and a loop edge's own condition never affects any status.

A cycle that passes through a decision is legal and leaves no cycle behind in the control graph. Any cycle that remains has no decision in it, can never make progress, and is returned by `illegalCycles` as data. A path follows `graph-dag`'s convention: each element needs the next and the last needs the first, repeated at the end, so `["x", "y", "x"]` means x needs y and y needs x.

## Status

Every element has one of five states, derived and never stored.

| Status | When |
| --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `done` | The overlay says `done`, or the element is a `decision` or `pseudo` element whose needs are met (see below). |
| `unknown` | The overlay says `unknown`, a transition that is not cut is `unknown` or has an indeterminate condition, or a consumed artefact has an unknown producer. |
| `skipped` | It has incoming transitions and every one is cut. |
| `ready` | Every transition that is not cut comes from a `done` source, and every consumed artefact is provided or has a `done` producer. |
| `blocked` | Anything else. |

An element with no overlay entry is open. The rules are applied in the order of the table, so `unknown` dominates `blocked`: an element with an unknown need is `unknown` even if another need is still open.

A **transition is cut** when its condition is definitely false or its source is `skipped`. A cut transition is not a need, whatever state its source is in. This is what lets a join be satisfied by a skipped branch: the rule above, applied to a `join` pseudo-state, is exactly "ready only when every incoming source is done or skipped", and a `fork` passes because it adds no condition of its own.

**Control elements are transparent.** A `decision` or `pseudo` element has no work for anyone to do and no source reports it, so when it would be `ready` it is `done`, and downstream elements are not held up by it. They never appear in the ready set. Their `unknown` and `skipped` states apply as for any element. `activity`, `state` and `message` elements are work and stay `ready` until the overlay says they are done.

**Artefacts.** A consumed artefact is satisfied when it is provided (see below) or a producer is `done`. An artefact whose producers are all skipped, or that has no producer and is not provided, is never satisfied, and its consumer is `blocked`.

## Decisions and facts

A transition's `condition` is a trilean `PredicateNode`, evaluated with trilean's synchronous evaluator. A definite true takes the branch (`taken`), a definite false does not (`not-taken`), and an indeterminate result (a fact the port cannot answer) leaves both branches possible: everything downstream is `unknown`, not `blocked` or `skipped`. A transition without a condition is always taken.

The `Facts` port is `(key: JsonValue) => Resolution`: an opaque key in, `{ found: true, value }` or `{ found: false }` out. A condition's `reference` keys reach it unchanged. Two keys are reserved, each an object with a single `$`-prefixed field, and a `Facts` implementation should not use that field.

| Key | Answered by | Meaning |
| ---------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `elementStatusKey(id)` | This package, from the overlay | The text `"done"` or `"open"`. Not found, so indeterminate, when the overlay says `unknown` or `id` is not an element. It reads the overlay, never the derived status, so no cycle is possible. |
| `artefactKey(id)` | The port: the package asks it | The boolean `true` when the input artefact is provided. Not found or `false` means it is not. |

`lookup`, `some`, `every` and fold conditions need tables or collections, which the port does not serve; they throw `UnsupportedConditionError` rather than being read as an empty collection.

## Queries

`blockedBy` starts at an element's unfinished needs and follows each one's own unfinished needs to the elements that wait on nothing further: the minimal set of open work to finish first. Cut transitions and provided artefacts are not followed. An element that is not blocked by anything returns an empty list.

`impact` follows live needs in reverse, so a branch that was not taken, a skipped element and an element already `done` carry no impact; `id` itself is not included.

`whatIfDone` derives the run again with `id` marked `done`, without mutating anything. `becomesReady` lists the elements that were not ready and are; `changes` lists every status that moved, in model order.

`criticalPath` runs `graph-dag`'s `criticalPath` over the live elements (neither `done` nor `skipped`), ordered by the live needs among them. Its tie-breaking rule holds: the earliest element in model order wins.

`unmetOutputs` reports each `deliverable` artefact that is not provided and has no `done` producer. `needed` is its unfinished producers and, transitively, everything those wait on; `missingInputs` is the artefacts among their consumed ones that no activity produces and the port does not report as provided.

## Commands

```sh
pnpm build # tsc -b -> dist/ (ESM + .d.ts)
pnpm test # unit tests
pnpm test:smoke # the built dist/ exercised as a real artefact
pnpm test:mutation # Stryker
```
9 changes: 9 additions & 0 deletions packages/core/graph/domain/eslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from "eslint/config";
import { packageLintConfig } from "../../../../eslint.shared.ts";

export default defineConfig(
packageLintConfig({
tsconfigRootDir: import.meta.dirname,
isomorphic: true,
}),
);
75 changes: 75 additions & 0 deletions packages/core/graph/domain/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"name": "@exadev/process-graph-domain",
"version": "0.0.0",
"description": "Reasoning over the process model and a run overlay: control graph, illegal cycles, three-valued status, decision evaluation, ready set, blocked-by, impact, what-if and critical path.",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/ExaDev/process-graph.git",
"directory": "packages/core/graph/domain"
},
"homepage": "https://github.com/ExaDev/process-graph/tree/main/packages/core/graph/domain#readme",
"bugs": {
"url": "https://github.com/ExaDev/process-graph/issues"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"publishConfig": {
"access": "public",
"provenance": true,
"registry": "https://registry.npmjs.org/"
},
"sideEffects": false,
"license": "MIT",
"scripts": {
"build": "turbo run _build",
"_build": "tsc -b tsconfig.build.json",
"lint": "turbo run _lint",
"_lint": "eslint . --cache --max-warnings 0",
"_lint:fix": "eslint . --fix --cache --max-warnings 0",
"typecheck": "turbo run _typecheck _typecheck:attw",
"_typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
"_typecheck:attw": "attw --pack --profile esm-only",
"test": "turbo run _test",
"_test": "vitest run --project unit",
"test:coverage": "turbo run _test:coverage",
"_test:coverage": "vitest run --project unit --coverage",
"test:smoke": "turbo run _test:smoke",
"_test:smoke": "vitest run --project smoke",
"test:mutation": "turbo run _test:mutation",
"_test:mutation": "stryker run stryker.config.ts",
"prepublishOnly": "pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run build && publint && attw --pack --profile esm-only"
},
"keywords": [
"process-model",
"dependency-graph",
"three-valued-logic",
"critical-path",
"what-if"
],
"devDependencies": {
"@arethetypeswrong/cli": "0.18.5",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
"@stryker-mutator/vitest-runner": "10.0.0",
"@types/node": "26.6.4",
"@vitest/coverage-v8": "4.1.11",
"eslint": "10.12.0",
"publint": "0.3.25",
"typescript": "6.0.3",
"vitest": "4.1.11"
},
"dependencies": {
"@exadev/process-graph-dag": "0.0.0",
"@exadev/process-graph-schema": "0.0.0",
"trilean": "1.7.2"
}
}
114 changes: 114 additions & 0 deletions packages/core/graph/domain/src/control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
buildDag,
descendantsOf,
findCycles,
type Dag,
} from "@exadev/process-graph-dag";
import type { IdSpace, Model } from "@exadev/process-graph-schema";

/**
* Builds the dependency graph over every element, leaving out the transitions in `excluded`. A node needs the source of each remaining incoming transition and, for an activity, the producer of each artefact it consumes.
*/
const buildControl = <I extends IdSpace>(
model: Model<I>,
excluded: ReadonlySet<I["transition"]>,
): Dag<I["element"]> => {
const sources = new Map<I["element"], I["element"][]>(
model.elements.map(({ id }) => [id, []]),
);
for (const transition of model.transitions) {
const incoming = sources.get(transition.target);
if (incoming !== undefined && !excluded.has(transition.id)) {
incoming.push(transition.source);
}
}

const producers = new Map<I["artefact"], I["element"][]>();
for (const element of model.elements) {
if (element.kind !== "activity") {
continue;
}
for (const artefact of element.produces ?? []) {
const known = producers.get(artefact);
if (known === undefined) {
producers.set(artefact, [element.id]);
} else {
known.push(element.id);
}
}
}

const consumed = new Map<I["element"], readonly I["artefact"][]>(
model.elements.flatMap((element) =>
element.kind === "activity" && element.consumes !== undefined
? [[element.id, element.consumes] as const]
: [],
),
);

return buildDag(
model.elements.map(({ id }) => id),
(id) => [
...(sources.get(id) ?? []),
...(consumed.get(id) ?? []).flatMap(
(artefact) => producers.get(artefact) ?? [],
),
],
);
};

/**
* The transitions that re-enter earlier work: a transition leaving a `decision` element whose target can reach its source (or is its source), so that it closes a cycle through the decision.
*
* Reachability is over the whole graph, transitions and artefact dependencies alike. Only a decision's own outgoing transitions are candidates: a cycle that contains no decision has no loop edge and is illegal.
* @param model - The model to read.
* @returns The loop transitions, in transition order.
* @throws UnknownNodeError when such a transition's target is not an element of the model.
*/
export const loopTransitions = <I extends IdSpace>(
model: Model<I>,
): readonly I["transition"][] => {
const decisions = new Set<I["element"]>(
model.elements.flatMap((element) =>
element.kind === "decision" ? [element.id] : [],
),
);
const whole = buildControl(model, new Set());

return model.transitions
.filter(
(transition) =>
decisions.has(transition.source) &&
(transition.target === transition.source ||
descendantsOf(whole, transition.target).includes(transition.source)),
)
.map(({ id }) => id);
};

/**
* The control graph the whole analysis is computed on, with the loop edges removed.
*
* Its nodes are the element ids. A node needs the source of every incoming transition that is not a loop edge (see `loopTransitions`) and, for an activity, the producer of every artefact in `consumes`. Excluding loop edges makes a legal retry or review loop acyclic here, so readiness, layers and the critical path are well defined: a loop edge is a way back to earlier work, never a prerequisite of it. A node whose only incoming transitions are loop edges therefore has no needs.
*
* The graph is not guaranteed acyclic: cycles that no decision closes remain and are reported by `illegalCycles`. A transition whose source is not an element appears in `unknownReferences`.
* @param model - The model to read.
* @returns The graph over element ids, as a `graph-dag` `Dag`.
* @throws UnknownNodeError when a decision's transition targets something that is not an element.
*/
export const controlDag = <I extends IdSpace>(
model: Model<I>,
): Dag<I["element"]> => buildControl(model, new Set(loopTransitions(model)));

/**
* The cycles of the control graph: every cycle that is not a legal decision loop.
*
* A cycle that passes through a `decision` element is a legal loop and is not reported; the loop edge that closes it is left out of `controlDag`. Any cycle that remains has no decision in it, so it can never make progress, and is reported with its path.
*
* A path follows `graph-dag`'s convention: each element needs the next one and the last needs the first, which is repeated at the end. `["a", "b", "a"]` means a needs b and b needs a. One path is reported per strongly connected component; break it and call again for the rest.
* @param model - The model to read.
* @returns The illegal cycles, empty exactly when every cycle is a legal decision loop.
* @throws UnknownNodeError when a decision's transition targets something that is not an element.
*/
export const illegalCycles = <I extends IdSpace>(
model: Model<I>,
): readonly (readonly I["element"][])[] => findCycles(controlDag(model));
Loading
Loading