diff --git a/README.md b/README.md index bb823d9..0d9cc75 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/core/graph/domain/AGENTS.md b/packages/core/graph/domain/AGENTS.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/domain/AGENTS.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/domain/CLAUDE.md b/packages/core/graph/domain/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/packages/core/graph/domain/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/packages/core/graph/domain/README.md b/packages/core/graph/domain/README.md new file mode 100644 index 0000000..093bb3b --- /dev/null +++ b/packages/core/graph/domain/README.md @@ -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` and `Overlay`, 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 +``` diff --git a/packages/core/graph/domain/eslint.config.ts b/packages/core/graph/domain/eslint.config.ts new file mode 100644 index 0000000..4c43201 --- /dev/null +++ b/packages/core/graph/domain/eslint.config.ts @@ -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, + }), +); diff --git a/packages/core/graph/domain/package.json b/packages/core/graph/domain/package.json new file mode 100644 index 0000000..1991fd7 --- /dev/null +++ b/packages/core/graph/domain/package.json @@ -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" + } +} diff --git a/packages/core/graph/domain/src/control.ts b/packages/core/graph/domain/src/control.ts new file mode 100644 index 0000000..208a2d7 --- /dev/null +++ b/packages/core/graph/domain/src/control.ts @@ -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 = ( + model: Model, + excluded: ReadonlySet, +): Dag => { + const sources = new Map( + 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(); + 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( + 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 = ( + model: Model, +): readonly I["transition"][] => { + const decisions = new Set( + 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 = ( + model: Model, +): Dag => 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 = ( + model: Model, +): readonly (readonly I["element"][])[] => findCycles(controlDag(model)); diff --git a/packages/core/graph/domain/src/control.unit.test.ts b/packages/core/graph/domain/src/control.unit.test.ts new file mode 100644 index 0000000..f28dc07 --- /dev/null +++ b/packages/core/graph/domain/src/control.unit.test.ts @@ -0,0 +1,76 @@ +import { CycleError } from "@exadev/process-graph-dag"; +import { describe, expect, it } from "vitest"; +import { controlDag, illegalCycles, loopTransitions } from "./control.ts"; +import { deriveStatuses } from "./derive.ts"; +import { noFacts } from "./test-support/conditions.ts"; +import { + approval, + chain, + diamond, + pipeline, + plainCycle, + retryLoop, +} from "./test-support/models.ts"; + +describe("controlDag", () => { + it("needs the sources of the incoming transitions", () => { + const dag = controlDag(diamond); + + expect(dag.nodes).toEqual(["a", "b", "c", "d"]); + expect(dag.needs.get("d")).toEqual(["b", "c"]); + expect(dag.needs.get("a")).toEqual([]); + }); + + it("adds the producer of every consumed artefact to an activity's needs", () => { + expect(controlDag(pipeline).needs.get("ship")).toEqual(["build"]); + expect(controlDag(pipeline).needs.get("build")).toEqual([]); + }); + + it("leaves the loop edge of a decision out of the needs", () => { + expect(controlDag(retryLoop).needs.get("write")).toEqual([]); + expect(controlDag(retryLoop).needs.get("publish")).toEqual(["review"]); + }); +}); + +describe("loopTransitions", () => { + it("names the decision transition that closes a cycle and no other", () => { + expect(loopTransitions(retryLoop)).toEqual(["retry"]); + }); + + it("does not take a decision's forward branches for loops", () => { + expect(loopTransitions(approval)).toEqual([]); + }); + + it("does not take a cycle without a decision for a loop", () => { + expect(loopTransitions(plainCycle)).toEqual([]); + }); +}); + +describe("illegalCycles", () => { + it("reports a plain cycle with its path", () => { + expect(illegalCycles(plainCycle)).toEqual([["x", "y", "x"]]); + }); + + it("accepts a retry loop through a decision", () => { + expect(illegalCycles(retryLoop)).toEqual([]); + }); + + it("finds none in an acyclic model", () => { + expect(illegalCycles(chain)).toEqual([]); + }); + + it("makes the derivation throw an error carrying the cycle", () => { + try { + deriveStatuses(plainCycle, {}, noFacts); + } catch (error) { + expect(error).toBeInstanceOf(CycleError); + expect(error instanceof CycleError ? error.cycles : []).toEqual([ + ["x", "y", "x"], + ]); + + return; + } + + throw new Error("expected deriveStatuses to throw"); + }); +}); diff --git a/packages/core/graph/domain/src/derive.ts b/packages/core/graph/domain/src/derive.ts new file mode 100644 index 0000000..a03b499 --- /dev/null +++ b/packages/core/graph/domain/src/derive.ts @@ -0,0 +1,173 @@ +import { + CycleError, + findCycles, + topologicalLayers, + type Dag, +} from "@exadev/process-graph-dag"; +import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; +import { controlDag, loopTransitions } from "./control.ts"; +import { evaluateBranches, isProvided, type Facts } from "./facts.ts"; +import { lookup } from "./lookup.ts"; +import type { Branch, Status } from "./status.ts"; + +/** Everything one derivation reads. */ +export interface Run { + readonly model: Model; + readonly overlay: Overlay; + readonly facts: Facts; +} + +/** The result of deriving a run: shared by every query so each computes the same state. */ +export interface Analysis { + /** The control graph with loop edges removed. */ + readonly dag: Dag; + readonly branches: ReadonlyMap; + readonly statuses: ReadonlyMap; + /** + * For each element, the elements its progress still depends on: the sources of its incoming transitions that are not cut (the condition is not definitely false and the source is not skipped) and the producers of the artefacts it consumes that are neither provided nor produced already, except producers that are skipped. Includes elements that are done. + */ + readonly liveNeeds: ReadonlyMap; +} + +/** + * Derives the status of every element and the structures the queries share. + * + * Elements are visited in dependency order of the control graph. For each element, with its incoming non-loop transitions: + * + * 1. A transition is cut when its condition is definitely false or its source is `skipped`. The others are live. + * 2. `done` when the overlay says `done`. + * 3. `unknown` when the overlay says `unknown`, a live transition's condition is indeterminate or its source is `unknown`, or a consumed artefact that is not provided and has no done producer has an `unknown` producer. + * 4. `skipped` when it has incoming transitions and every one is cut. + * 5. `ready` when every live transition's source is `done` and each consumed artefact is provided or has a done producer; `blocked` otherwise. This single rule is also the join rule: a join, like any element, waits for every incoming source that is not skipped. + * 6. A `decision` or `pseudo` element has no work of its own: when the rule above gives it `ready`, it is `done`, so downstream elements are not held up by a step nobody can perform. An overlay `unknown` still applies to it. + * + * An element with no overlay entry is open. `unknown` dominates `blocked`: an element that has an unknown need is `unknown` even if another need is open. + */ +export const analyse = ({ + model, + overlay, + facts, +}: Readonly>): Analysis => { + const loops = new Set(loopTransitions(model)); + const dag = controlDag(model); + const cycles = findCycles(dag); + if (cycles.length > 0) { + throw new CycleError(cycles); + } + + const branches = evaluateBranches(model, overlay, facts); + const elements = new Map( + model.elements.map((element) => [element.id, element]), + ); + const incoming = new Map( + model.elements.map(({ id }) => [id, []]), + ); + for (const transition of model.transitions) { + if (!loops.has(transition.id)) { + lookup(incoming, transition.target).push(transition.id); + } + } + const transitions = new Map( + model.transitions.map((transition) => [transition.id, transition]), + ); + const producers = new Map(); + for (const element of model.elements) { + if (element.kind === "activity") { + for (const artefact of element.produces ?? []) { + producers.set(artefact, [ + ...(producers.get(artefact) ?? []), + element.id, + ]); + } + } + } + + const statuses = new Map(); + const liveNeeds = new Map(); + for (const id of topologicalLayers(dag).layers.flat()) { + const element = lookup(elements, id); + const edges = lookup(incoming, id).map((transitionId) => ({ + source: lookup(transitions, transitionId).source, + branch: lookup(branches, transitionId), + })); + const live = edges.filter( + ({ source, branch }) => + branch !== "not-taken" && lookup(statuses, source) !== "skipped", + ); + + const dataNeeds = new Set(); + let dataUnknown = false; + let dataUnmet = false; + for (const artefact of element.kind === "activity" + ? (element.consumes ?? []) + : []) { + const makers = producers.get(artefact) ?? []; + const made = makers.map((maker) => lookup(statuses, maker)); + if (isProvided(facts, artefact) || made.includes("done")) { + continue; + } + dataUnmet = true; + dataUnknown ||= made.includes("unknown"); + for (const maker of makers) { + if (lookup(statuses, maker) !== "skipped") { + dataNeeds.add(maker); + } + } + } + liveNeeds.set(id, [ + ...new Set([...live.map(({ source }) => source), ...dataNeeds]), + ]); + + const own = overlay[id]; + let status: Status; + if (own === "done") { + status = "done"; + } else if ( + own === "unknown" || + dataUnknown || + live.some( + ({ source, branch }) => + branch === "indeterminate" || lookup(statuses, source) === "unknown", + ) + ) { + status = "unknown"; + } else if (edges.length > 0 && live.length === 0) { + status = "skipped"; + } else if ( + !dataUnmet && + live.every(({ source }) => lookup(statuses, source) === "done") + ) { + status = + element.kind === "decision" || element.kind === "pseudo" + ? "done" + : "ready"; + } else { + status = "blocked"; + } + statuses.set(id, status); + } + + return { + dag, + branches, + statuses: new Map(dag.nodes.map((id) => [id, lookup(statuses, id)])), + liveNeeds, + }; +}; + +/** + * The derived status of every element. See `analyse` for the rules; the semantics are documented in the package README. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided (see `artefactKey`). + * @returns A status for every element, in model order. + * @throws CycleError when the control graph contains an illegal cycle (see `illegalCycles`), since status is undefined on it. + * @throws UnknownNodeError when a transition refers to an element the model does not declare. + * @throws UnsupportedConditionError when a condition needs a table or collection from the facts port. + */ +export const deriveStatuses = ( + model: Model, + overlay: Readonly>, + facts: Facts, +): ReadonlyMap => + analyse({ model, overlay, facts }).statuses; diff --git a/packages/core/graph/domain/src/facts.ts b/packages/core/graph/domain/src/facts.ts new file mode 100644 index 0000000..7dc89e6 --- /dev/null +++ b/packages/core/graph/domain/src/facts.ts @@ -0,0 +1,137 @@ +import type { JsonValue, Resolution, SyncResolvers } from "trilean"; +import { createSyncEvaluator } from "trilean"; +import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; +import type { Branch } from "./status.ts"; + +/** + * The injected source of facts a condition reads: an opaque, JSON-valued key in, a found or not-found value out (trilean's `Resolution`). A key the port cannot answer is `{ found: false }`, which makes any condition that depends on it indeterminate, never false. + * + * Keys of the reserved shape built by `elementStatusKey` are answered by this package from the overlay and never reach the port. The port is asked keys of the shape built by `artefactKey` to learn which input artefacts are provided; it should answer every other key it does not know with `{ found: false }`. + */ +export type Facts = (key: JsonValue) => Resolution; + +/** The only field of a reserved key. Keys that carry it are resolved from the overlay, so a `Facts` implementation must not use it. */ +const elementField = "$element"; + +/** + * Builds the reserved key that reads another element's run state inside a condition. It resolves to the text `"done"` or `"open"`, and is not found (so the condition is indeterminate) when the overlay says `"unknown"` or the id is not an element of the model. + * + * The value is the overlay's own state, not the derived status: a condition therefore never depends on the derivation that evaluates it, and cannot form a cycle. + * @param id - The element whose state is read. + * @returns A key to put in a trilean `reference` node. + */ +export const elementStatusKey = (id: string): JsonValue => ({ + [elementField]: id, +}); + +/** The only field of the key the package puts to the port to ask whether an input artefact is provided. */ +const artefactField = "$artefact"; + +/** + * Builds the key the package asks the facts port when it needs to know whether an artefact has been provided from outside the process. An artefact is provided exactly when the port answers `{ found: true }` with the boolean `true`; a not-found or `false` answer means it is not. + * + * A provided artefact satisfies every consumer, whether or not an activity also produces it. + * @param id - The artefact. + * @returns The key the port is asked. + */ +export const artefactKey = (id: string): JsonValue => ({ + [artefactField]: id, +}); + +/** + * Whether the facts port reports an artefact as provided. + * @param facts - The injected source of facts. + * @param id - The artefact. + */ +export const isProvided = (facts: Facts, id: string): boolean => { + const answer = facts(artefactKey(id)); + + return answer.found && answer.value.kind === "boolean" && answer.value.value; +}; + +/** + * Thrown when a condition uses a node whose evaluation needs a table or a collection from the facts port, which answers single keys only. + */ +export class UnsupportedConditionError extends Error { + /** The kind of reference the condition used. */ + readonly reference: "lookup" | "collection"; + + /** + * @param reference - The unsupported kind of reference. + */ + constructor(reference: "lookup" | "collection") { + super( + `A condition uses a ${reference} reference, which the Facts port does not answer: it resolves single keys only`, + ); + this.name = "UnsupportedConditionError"; + this.reference = reference; + } +} + +const evaluator = createSyncEvaluator({}); + +const isRecord = (value: JsonValue): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Evaluates every transition's condition. + * + * A definite true is `taken`, a definite false is `not-taken` and an indeterminate result is `indeterminate`. A transition without a condition is `taken`. Conditions are evaluated for every transition regardless of the state of its source. + * @param model - The model whose transitions are evaluated. + * @param overlay - Run state, read by `elementStatusKey` references. + * @param facts - The injected source of facts. + * @returns The outcome of every transition, in transition order. + * @throws UnsupportedConditionError when a condition contains a `lookup`, `some`, `every` or fold node. + */ +export const evaluateBranches = ( + model: Model, + overlay: Readonly>, + facts: Facts, +): ReadonlyMap => { + const elementIds = new Set(model.elements.map(({ id }) => id)); + const overlayOf = new Map(Object.entries(overlay)); + const resolvers: SyncResolvers = { + resolveValue: (key) => { + if (!isRecord(key) || !(elementField in key)) { + return facts(key); + } + const id = key[elementField]; + if (typeof id !== "string" || !elementIds.has(id)) { + return { found: false }; + } + const state = overlayOf.get(id); + if (state === "unknown") { + return { found: false }; + } + + return { + found: true, + value: { kind: "text", value: state === "done" ? "done" : "open" }, + }; + }, + resolveLookup: () => { + throw new UnsupportedConditionError("lookup"); + }, + resolveCollection: () => { + throw new UnsupportedConditionError("collection"); + }, + }; + + return new Map( + model.transitions.map(({ id, condition }) => { + if (condition === undefined) { + return [id, "taken"]; + } + const result = evaluator.evaluatePredicate( + condition, + undefined, + resolvers, + ); + if (result.status === "indeterminate") { + return [id, "indeterminate"]; + } + + return [id, result.value ? "taken" : "not-taken"]; + }), + ); +}; diff --git a/packages/core/graph/domain/src/index.ts b/packages/core/graph/domain/src/index.ts new file mode 100644 index 0000000..193838a --- /dev/null +++ b/packages/core/graph/domain/src/index.ts @@ -0,0 +1,24 @@ +/** + * Reasoning over the process model and a run overlay: the control graph, illegal cycles, three-valued status, decision evaluation and the queries built on them. Pure functions over `Model` and an `Overlay`; run state never enters the model. + */ +export { statuses, type Branch, type Status } from "./status.ts"; +export { + artefactKey, + elementStatusKey, + evaluateBranches, + UnsupportedConditionError, + type Facts, +} from "./facts.ts"; +export { controlDag, illegalCycles, loopTransitions } from "./control.ts"; +export { deriveStatuses } from "./derive.ts"; +export { + blockedBy, + criticalPath, + impact, + readySet, + unmetOutputs, + whatIfDone, + type StatusChange, + type UnmetOutput, + type WhatIf, +} from "./queries.ts"; diff --git a/packages/core/graph/domain/src/lookup.ts b/packages/core/graph/domain/src/lookup.ts new file mode 100644 index 0000000..8fe6334 --- /dev/null +++ b/packages/core/graph/domain/src/lookup.ts @@ -0,0 +1,17 @@ +import { UnknownNodeError, type NodeId } from "@exadev/process-graph-dag"; + +/** + * Reads a value out of a map built over every element of a model. + * @throws UnknownNodeError when the key is absent, which for such a map means the model refers to an element it does not declare. + */ +export const lookup = ( + map: ReadonlyMap, + id: Id, +): Value => { + const value = map.get(id); + if (value === undefined) { + throw new UnknownNodeError(id); + } + + return value; +}; diff --git a/packages/core/graph/domain/src/queries.ts b/packages/core/graph/domain/src/queries.ts new file mode 100644 index 0000000..d3aa667 --- /dev/null +++ b/packages/core/graph/domain/src/queries.ts @@ -0,0 +1,285 @@ +import { + buildDag, + criticalPath as longestPath, + type CriticalPath, +} from "@exadev/process-graph-dag"; +import type { IdSpace, Model, Overlay } from "@exadev/process-graph-schema"; +import { analyse, type Analysis } from "./derive.ts"; +import { isProvided, type Facts } from "./facts.ts"; +import { lookup } from "./lookup.ts"; +import type { Status } from "./status.ts"; + +/** Whether an element still has work ahead of it: it is neither finished nor going to be skipped. */ +const isLive = (status: Status): boolean => + status !== "done" && status !== "skipped"; + +/** The live needs of an element that are not done: what it is still waiting for. */ +const waitingOn = ( + analysis: Analysis, + id: I["element"], +): readonly I["element"][] => + lookup(analysis.liveNeeds, id).filter( + (need) => lookup(analysis.statuses, need) !== "done", + ); + +/** + * The elements that can be worked on now: every element whose status is `ready`, in model order. Pure control elements (decisions and pseudo-states) are derived `done` and never appear. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @returns The ready elements. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const readySet = ( + model: Model, + overlay: Readonly>, + facts: Facts, +): readonly I["element"][] => + [...analyse({ model, overlay, facts }).statuses] + .filter(([, status]) => status === "ready") + .map(([id]) => id); + +/** + * What is holding an element back: the minimal set of open elements to finish first. + * + * Starting from the element's unfinished needs, follows each one's own unfinished needs until it reaches elements that wait on nothing further, and returns exactly those leaves, in model order. Cut transitions (a branch not taken, a skipped source) and provided artefacts are not needs. A leaf is open work that is `ready`, or an element whose own state or condition is `unknown`. An element that is `done`, `skipped` or `ready` is blocked by nothing, and neither is one whose only obstacle is its own unknown state. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @param id - The element asked about. + * @returns The leaves of the unfinished-needs closure, in model order. + * @throws UnknownNodeError when `id` is not an element. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const blockedBy = ( + model: Model, + overlay: Readonly>, + facts: Facts, + id: I["element"], +): readonly I["element"][] => { + const analysis = analyse({ model, overlay, facts }); + lookup(analysis.statuses, id); + const leaves = new Set(); + const seen = new Set(); + const pending = [...waitingOn(analysis, id)]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (seen.has(next)) { + continue; + } + seen.add(next); + const further = waitingOn(analysis, next); + if (further.length === 0) { + leaves.add(next); + } else { + pending.push(...further); + } + } + + return analysis.dag.nodes.filter((node) => leaves.has(node)); +}; + +/** + * What finishing, delaying or dropping an element affects: the elements that transitively depend on it through paths that are still live. + * + * Traversal follows each dependent's live needs (see `Analysis.liveNeeds`), so a transition cut by a branch not taken, a skipped element and a loop edge carry no impact, and an element already `done` is neither affected nor traversed through. The element itself is not included. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @param id - The element asked about. + * @returns The affected elements, in model order. + * @throws UnknownNodeError when `id` is not an element. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const impact = ( + model: Model, + overlay: Readonly>, + facts: Facts, + id: I["element"], +): readonly I["element"][] => { + const analysis = analyse({ model, overlay, facts }); + lookup(analysis.statuses, id); + const live = analysis.dag.nodes.filter((node) => + isLive(lookup(analysis.statuses, node)), + ); + const affected = new Set(); + const pending = [id]; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + for (const node of live) { + if ( + !affected.has(node) && + node !== id && + lookup(analysis.liveNeeds, node).includes(next) + ) { + affected.add(node); + pending.push(node); + } + } + } + + return live.filter((node) => affected.has(node)); +}; + +/** A status that changed. */ +export interface StatusChange { + readonly id: I["element"]; + readonly before: Status; + readonly after: Status; +} + +/** The effect of finishing one element. */ +export interface WhatIf { + /** Elements that were not ready and become ready, in model order. */ + readonly becomesReady: readonly I["element"][]; + /** Every element whose status changes, in model order. Includes the finished element itself unless it was done already. */ + readonly changes: readonly StatusChange[]; +} + +/** + * Derives the run again with an element marked `done` in the overlay and reports what changes. Nothing is mutated. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @param id - The element to finish. + * @returns The elements that become ready and every status change. + * @throws UnknownNodeError when `id` is not an element. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const whatIfDone = ( + model: Model, + overlay: Readonly>, + facts: Facts, + id: I["element"], +): WhatIf => { + const before = analyse({ model, overlay, facts }).statuses; + lookup(before, id); + const after = analyse({ + model, + overlay: { ...overlay, [id]: "done" }, + facts, + }).statuses; + const changes = [...after].flatMap(([element, status]) => { + const was = lookup(before, element); + + return was === status ? [] : [{ id: element, before: was, after: status }]; + }); + + return { + becomesReady: changes + .filter((change) => change.after === "ready") + .map((change) => change.id), + changes, + }; +}; + +/** + * The heaviest chain through the work that is still ahead: the live elements, those that are neither `done` nor `skipped`, ordered by the live needs that are themselves live. + * + * A finished or skipped element contributes nothing and does not appear. The chain is computed with `graph-dag`'s `criticalPath`, so its tie-breaking rule holds (the earliest element in model order wins). + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @param weight - The cost of an element: finite and not negative. + * @returns The path in dependency order and its total weight; empty when nothing is live. + * @throws RangeError when `weight` returns a negative or non-finite number. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const criticalPath = ( + model: Model, + overlay: Readonly>, + facts: Facts, + weight: (id: I["element"]) => number, +): CriticalPath => { + const analysis = analyse({ model, overlay, facts }); + const live = analysis.dag.nodes.filter((node) => + isLive(lookup(analysis.statuses, node)), + ); + + return longestPath( + buildDag(live, (id) => + lookup(analysis.liveNeeds, id).filter((need) => + isLive(lookup(analysis.statuses, need)), + ), + ), + weight, + ); +}; + +/** A deliverable artefact that has not been produced yet and what it still takes. */ +export interface UnmetOutput { + readonly artefact: I["artefact"]; + /** The activities that declare they produce it. Empty when none does, so it can never be produced. */ + readonly producers: readonly I["element"][]; + /** The open elements still needed to produce it: its unfinished producers and, transitively, every unfinished live need of those, in model order. */ + readonly needed: readonly I["element"][]; + /** The artefacts among those needs that nothing produces and the run has not provided, in declaration order. */ + readonly missingInputs: readonly I["artefact"][]; +} + +/** + * Every deliverable artefact that is still to be produced, with what is needed to produce it. + * + * A deliverable is met when it is provided or one of its producers is `done`. An unmet one lists its producers, the unfinished elements that must finish (its unfinished producers and everything those transitively wait on, skipped elements excluded) and the inputs that no one produces and nobody has provided, so a deliverable that can only ever be reached through an unproduced input shows exactly which input is missing. + * @param model - The model to read. + * @param overlay - Run state by element id. + * @param facts - The injected source of facts conditions read, and of which input artefacts are provided. + * @returns One entry per unmet deliverable, in declaration order. + * @throws CycleError when the control graph has an illegal cycle. + */ +export const unmetOutputs = ( + model: Model, + overlay: Readonly>, + facts: Facts, +): readonly UnmetOutput[] => { + const analysis = analyse({ model, overlay, facts }); + const producersOf = (artefact: I["artefact"]): readonly I["element"][] => + model.elements.flatMap((element) => + element.kind === "activity" && + element.produces?.includes(artefact) === true + ? [element.id] + : [], + ); + const unmet: UnmetOutput[] = []; + for (const { id: artefact, deliverable } of model.artefacts) { + const producers = producersOf(artefact); + if ( + deliverable !== true || + isProvided(facts, artefact) || + producers.some((id) => lookup(analysis.statuses, id) === "done") + ) { + continue; + } + const needed = new Set(); + const pending = producers.filter((id) => + isLive(lookup(analysis.statuses, id)), + ); + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + if (needed.has(next)) { + continue; + } + needed.add(next); + pending.push(...waitingOn(analysis, next)); + } + const wanted = new Set(); + for (const element of model.elements) { + if (element.kind !== "activity" || !needed.has(element.id)) { + continue; + } + for (const consumed of element.consumes ?? []) { + if ( + !isProvided(facts, consumed) && + producersOf(consumed).length === 0 + ) { + wanted.add(consumed); + } + } + } + unmet.push({ + artefact, + producers, + needed: analysis.dag.nodes.filter((id) => needed.has(id)), + missingInputs: [...wanted], + }); + } + + return unmet; +}; diff --git a/packages/core/graph/domain/src/queries.unit.test.ts b/packages/core/graph/domain/src/queries.unit.test.ts new file mode 100644 index 0000000..7a3e312 --- /dev/null +++ b/packages/core/graph/domain/src/queries.unit.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; +import { + blockedBy, + criticalPath, + impact, + unmetOutputs, + whatIfDone, +} from "./queries.ts"; +import { factsOf, noFacts } from "./test-support/conditions.ts"; +import { approval, chain, diamond, pipeline } from "./test-support/models.ts"; + +describe("blockedBy", () => { + it("is the first open element of a chain", () => { + expect(blockedBy(chain, {}, noFacts, "c")).toEqual(["a"]); + expect(blockedBy(chain, { a: "done" }, noFacts, "c")).toEqual(["b"]); + }); + + it("is every open leaf behind a join", () => { + expect(blockedBy(diamond, { a: "done" }, noFacts, "d")).toEqual(["b", "c"]); + expect(blockedBy(diamond, {}, noFacts, "d")).toEqual(["a"]); + }); + + it("is empty for an element that is ready or done", () => { + expect(blockedBy(chain, {}, noFacts, "a")).toEqual([]); + expect(blockedBy(chain, { a: "done" }, noFacts, "a")).toEqual([]); + }); + + it("names an element whose state is unknown as the leaf", () => { + expect(blockedBy(chain, { a: "unknown" }, noFacts, "c")).toEqual(["a"]); + }); + + it("does not follow a branch that was not taken", () => { + expect( + blockedBy( + approval, + { triage: "done" }, + factsOf({ approved: false }), + "close", + ), + ).toEqual(["reject"]); + }); +}); + +describe("impact", () => { + it("is every transitive dependent of an open element", () => { + expect(impact(chain, {}, noFacts, "a")).toEqual(["b", "c"]); + }); + + it("leaves out dependents on branches that are not taken", () => { + expect(impact(approval, {}, factsOf({ approved: true }), "triage")).toEqual( + ["choose", "accept", "notify", "close"], + ); + }); + + it("does not pass through work that is already done", () => { + expect(impact(chain, { b: "done" }, noFacts, "a")).toEqual([]); + }); +}); + +describe("whatIfDone", () => { + it("reports what a chain's head unblocks", () => { + expect(whatIfDone(chain, {}, noFacts, "a")).toEqual({ + becomesReady: ["b"], + changes: [ + { id: "a", before: "ready", after: "done" }, + { id: "b", before: "blocked", after: "ready" }, + ], + }); + }); + + it("reports nothing for an element that is already done", () => { + expect(whatIfDone(chain, { a: "done" }, noFacts, "a")).toEqual({ + becomesReady: [], + changes: [], + }); + }); + + it("reports a skip that finishing a decision's input would not change", () => { + const result = whatIfDone( + approval, + {}, + factsOf({ approved: false }), + "triage", + ); + + expect(result.becomesReady).toEqual(["reject"]); + expect(result.changes).toEqual([ + { id: "triage", before: "ready", after: "done" }, + { id: "choose", before: "blocked", after: "done" }, + { id: "reject", before: "blocked", after: "ready" }, + ]); + }); +}); + +describe("criticalPath", () => { + const weights: Readonly> = { a: 1, b: 5, c: 2, d: 1 }; + const weight = (id: string): number => weights[id] ?? 0; + + it("follows the heaviest live chain", () => { + expect(criticalPath(diamond, {}, noFacts, weight)).toEqual({ + path: ["a", "b", "d"], + weight: 7, + }); + }); + + it("drops finished work from the path and the weight", () => { + expect(criticalPath(diamond, { a: "done" }, noFacts, weight)).toEqual({ + path: ["b", "d"], + weight: 6, + }); + }); + + it("leaves a skipped branch out even when it is the heaviest", () => { + const outweighsAll = 100; + const heavy = (id: string): number => (id === "reject" ? outweighsAll : 1); + + expect( + criticalPath( + approval, + { triage: "done" }, + factsOf({ approved: true }), + heavy, + ), + ).toEqual({ path: ["accept", "notify", "close"], weight: 3 }); + }); + + it("is empty when nothing is left to do", () => { + expect( + criticalPath(chain, { a: "done", b: "done", c: "done" }, noFacts, weight), + ).toEqual({ path: [], weight: 0 }); + }); +}); + +describe("unmetOutputs", () => { + it("shows a deliverable reachable only through an input nobody provided", () => { + expect(unmetOutputs(pipeline, {}, noFacts)).toEqual([ + { + artefact: "release", + producers: ["ship"], + needed: ["build", "ship"], + missingInputs: ["spec"], + }, + ]); + }); + + it("drops the missing input once it is provided", () => { + expect(unmetOutputs(pipeline, {}, factsOf({}, ["spec"]))).toEqual([ + { + artefact: "release", + producers: ["ship"], + needed: ["build", "ship"], + missingInputs: [], + }, + ]); + }); + + it("narrows to what is still open as work finishes", () => { + expect( + unmetOutputs(pipeline, { build: "done" }, noFacts).map( + ({ needed }) => needed, + ), + ).toEqual([["ship"]]); + }); + + it("reports nothing once the deliverable's producer is done", () => { + expect( + unmetOutputs(pipeline, { build: "done", ship: "done" }, noFacts), + ).toEqual([]); + }); +}); diff --git a/packages/core/graph/domain/src/status.ts b/packages/core/graph/domain/src/status.ts new file mode 100644 index 0000000..d9544d1 --- /dev/null +++ b/packages/core/graph/domain/src/status.ts @@ -0,0 +1,24 @@ +/** The derived states of an element, in the order the derivation considers them. */ +export const statuses = [ + "done", + "unknown", + "skipped", + "ready", + "blocked", +] as const; + +/** + * Where an element stands in a run, derived from the model, the run overlay and the facts, never stored. + * + * - `done`: finished, as reported by the overlay or, for a pure control element, derived. + * - `unknown`: cannot be told apart, because the overlay says so, a need is unknown, or a condition on an incoming transition is indeterminate. + * - `skipped`: every incoming path is cut, so the element will not run. + * - `ready`: not done, with every live need done and every consumed artefact produced or provided. + * - `blocked`: not done and still waiting. + */ +export type Status = (typeof statuses)[number]; + +/** + * What a transition's `condition` evaluates to: `taken`, `not-taken`, or `indeterminate` when facts the condition reads are missing, so both outcomes stay possible. A transition without a condition is always `taken`. + */ +export type Branch = "taken" | "not-taken" | "indeterminate"; diff --git a/packages/core/graph/domain/src/status.unit.test.ts b/packages/core/graph/domain/src/status.unit.test.ts new file mode 100644 index 0000000..5ee1de7 --- /dev/null +++ b/packages/core/graph/domain/src/status.unit.test.ts @@ -0,0 +1,338 @@ +import { describe, expect, it } from "vitest"; +import { deriveStatuses } from "./derive.ts"; +import { evaluateBranches, UnsupportedConditionError } from "./facts.ts"; +import { readySet } from "./queries.ts"; +import { factsOf, noFacts } from "./test-support/conditions.ts"; +import { + approval, + branchJoin, + chain, + diamond, + forkJoin, + pipeline, + retryLoop, + reviewGate, +} from "./test-support/models.ts"; +import { defineModel } from "@exadev/process-graph-schema"; + +const entries = (statuses: ReadonlyMap) => [...statuses]; + +describe("status of a diamond", () => { + it("is ready at the root and blocked behind it", () => { + expect(entries(deriveStatuses(diamond, {}, noFacts))).toEqual([ + ["a", "ready"], + ["b", "blocked"], + ["c", "blocked"], + ["d", "blocked"], + ]); + }); + + it("opens both branches when the root is done and keeps the sink waiting", () => { + const statuses = deriveStatuses(diamond, { a: "done" }, noFacts); + + expect(entries(statuses)).toEqual([ + ["a", "done"], + ["b", "ready"], + ["c", "ready"], + ["d", "blocked"], + ]); + }); + + it("makes the sink ready only once both branches are done", () => { + expect(readySet(diamond, { a: "done", b: "done" }, noFacts)).toEqual(["c"]); + expect( + readySet(diamond, { a: "done", b: "done", c: "done" }, noFacts), + ).toEqual(["d"]); + }); +}); + +describe("unknown overlay state", () => { + it("makes the element and everything downstream unknown", () => { + const statuses = deriveStatuses(chain, { a: "unknown" }, noFacts); + + expect(entries(statuses)).toEqual([ + ["a", "unknown"], + ["b", "unknown"], + ["c", "unknown"], + ]); + }); + + it("does not disturb an element that is done itself", () => { + const statuses = deriveStatuses( + chain, + { a: "unknown", b: "done" }, + noFacts, + ); + + expect(entries(statuses)).toEqual([ + ["a", "unknown"], + ["b", "done"], + ["c", "ready"], + ]); + }); +}); + +describe("a decision's condition", () => { + const done = { triage: "done" } as const; + + it("takes the true branch and skips the false one and what only it reaches", () => { + const statuses = deriveStatuses( + approval, + done, + factsOf({ approved: true }), + ); + + expect(entries(statuses)).toEqual([ + ["triage", "done"], + ["choose", "done"], + ["accept", "ready"], + ["reject", "skipped"], + ["notify", "blocked"], + ["close", "blocked"], + ]); + expect(readySet(approval, done, factsOf({ approved: true }))).toEqual([ + "accept", + ]); + }); + + it("takes the false branch and skips the true one and what only it reaches", () => { + const statuses = deriveStatuses( + approval, + done, + factsOf({ approved: false }), + ); + + expect(entries(statuses)).toEqual([ + ["triage", "done"], + ["choose", "done"], + ["accept", "skipped"], + ["reject", "ready"], + ["notify", "skipped"], + ["close", "blocked"], + ]); + expect(readySet(approval, done, factsOf({ approved: false }))).toEqual([ + "reject", + ]); + }); + + it("leaves both branches and everything downstream unknown when the fact is missing", () => { + const statuses = deriveStatuses(approval, done, noFacts); + + expect(entries(statuses)).toEqual([ + ["triage", "done"], + ["choose", "done"], + ["accept", "unknown"], + ["reject", "unknown"], + ["notify", "unknown"], + ["close", "unknown"], + ]); + expect(readySet(approval, done, noFacts)).toEqual([]); + }); + + it("evaluates every transition, including those of an element that is not done", () => { + expect([ + ...evaluateBranches(approval, {}, factsOf({ approved: true })), + ]).toEqual([ + ["t-choose", "taken"], + ["t-accept", "taken"], + ["t-reject", "not-taken"], + ["t-notify", "taken"], + ["t-close-a", "taken"], + ["t-close-r", "taken"], + ]); + }); +}); + +describe("joins", () => { + it("waits for both branches of a fork", () => { + const base = { a: "done" } as const; + + expect(entries(deriveStatuses(forkJoin, base, noFacts))).toEqual([ + ["a", "done"], + ["fork", "done"], + ["b", "ready"], + ["c", "ready"], + ["join", "blocked"], + ["e", "blocked"], + ]); + expect( + entries(deriveStatuses(forkJoin, { ...base, b: "done" }, noFacts)), + ).toEqual([ + ["a", "done"], + ["fork", "done"], + ["b", "done"], + ["c", "ready"], + ["join", "blocked"], + ["e", "blocked"], + ]); + expect( + entries( + deriveStatuses(forkJoin, { ...base, b: "done", c: "done" }, noFacts), + ), + ).toEqual([ + ["a", "done"], + ["fork", "done"], + ["b", "done"], + ["c", "done"], + ["join", "done"], + ["e", "ready"], + ]); + }); + + it("is satisfied by a branch that was skipped", () => { + const run = { a: "done", b: "done" } as const; + + expect( + entries(deriveStatuses(branchJoin, run, factsOf({ fast: true }))), + ).toEqual([ + ["a", "done"], + ["pick", "done"], + ["b", "done"], + ["c", "skipped"], + ["join", "done"], + ["e", "ready"], + ]); + }); + + it("is not satisfied while the live branch is still open", () => { + expect( + entries( + deriveStatuses(branchJoin, { a: "done" }, factsOf({ fast: true })), + ), + ).toEqual([ + ["a", "done"], + ["pick", "done"], + ["b", "ready"], + ["c", "skipped"], + ["join", "blocked"], + ["e", "blocked"], + ]); + }); +}); + +describe("a retry loop", () => { + it("computes readiness on the graph without the loop edge", () => { + expect(readySet(retryLoop, {}, noFacts)).toEqual(["write"]); + }); + + it("ignores the loop edge's own condition, even when it is indeterminate", () => { + const statuses = deriveStatuses( + retryLoop, + { write: "done" }, + factsOf({ passed: true }), + ); + + expect(entries(statuses)).toEqual([ + ["write", "done"], + ["review", "done"], + ["publish", "ready"], + ]); + expect( + entries(deriveStatuses(retryLoop, { write: "done" }, noFacts)), + ).toEqual([ + ["write", "done"], + ["review", "done"], + ["publish", "unknown"], + ]); + }); +}); + +describe("artefacts", () => { + it("blocks a consumer until the input is provided", () => { + expect(entries(deriveStatuses(pipeline, {}, noFacts))).toEqual([ + ["build", "blocked"], + ["ship", "blocked"], + ]); + expect( + entries(deriveStatuses(pipeline, {}, factsOf({}, ["spec"]))), + ).toEqual([ + ["build", "ready"], + ["ship", "blocked"], + ]); + }); + + it("makes a consumer ready when its producer is done", () => { + expect( + entries(deriveStatuses(pipeline, { build: "done" }, noFacts)), + ).toEqual([ + ["build", "done"], + ["ship", "ready"], + ]); + }); + + it("makes a consumer unknown when the producer is unknown", () => { + expect( + entries(deriveStatuses(pipeline, { build: "unknown" }, noFacts)), + ).toEqual([ + ["build", "unknown"], + ["ship", "unknown"], + ]); + }); + + it("lets a provided artefact stand in for its unknown producer", () => { + const statuses = deriveStatuses( + pipeline, + { build: "unknown" }, + factsOf({}, ["bundle"]), + ); + + expect(statuses.get("ship")).toBe("ready"); + }); +}); + +describe("element status references", () => { + it("reads the overlay state: done is taken, open is not, unknown is indeterminate", () => { + const branch = (state: "done" | "open" | "unknown") => + evaluateBranches(reviewGate, { audit: state }, noFacts).get( + "gate-proceed", + ); + + expect(branch("done")).toBe("taken"); + expect(branch("open")).toBe("not-taken"); + expect(branch("unknown")).toBe("indeterminate"); + }); + + it("treats an element with no overlay entry as open", () => { + expect(evaluateBranches(reviewGate, {}, noFacts).get("gate-proceed")).toBe( + "not-taken", + ); + }); +}); + +describe("unsupported conditions", () => { + it("fails loudly on a condition that needs a collection", () => { + const model = defineModel({ + participants: [{ id: "team", label: "Team", kind: "role" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "team" }, + { kind: "activity", id: "b", label: "B", actor: "team" }, + ], + transitions: [ + { + id: "ab", + source: "a", + target: "b", + condition: { + kind: "every", + collection: "checks", + item: { + kind: "compare", + op: "eq", + left: { kind: "reference", key: "ok" }, + right: { kind: "booleanLiteral", value: true }, + }, + }, + }, + ], + fragments: [], + projections: [], + }); + + expect(() => evaluateBranches(model, {}, noFacts)).toThrow( + UnsupportedConditionError, + ); + }); +}); diff --git a/packages/core/graph/domain/src/test-support/conditions.ts b/packages/core/graph/domain/src/test-support/conditions.ts new file mode 100644 index 0000000..f6ff9ac --- /dev/null +++ b/packages/core/graph/domain/src/test-support/conditions.ts @@ -0,0 +1,42 @@ +import type { PredicateNode } from "trilean"; +import { artefactKey, type Facts } from "../facts.ts"; + +/** A condition that holds when the boolean fact under `key` is true. */ +export const holds = (key: string): PredicateNode => ({ + kind: "compare", + op: "eq", + left: { kind: "reference", key }, + right: { kind: "booleanLiteral", value: true }, +}); + +/** The negation of `holds`: definitely false when the fact is true, and indeterminate when the fact is missing. */ +export const fails = (key: string): PredicateNode => ({ + kind: "not", + operand: holds(key), +}); + +/** + * A facts port answering the given string keys with booleans, reporting each artefact in `provided` as provided, and answering every other key as not found. + */ +export const factsOf = + ( + values: Readonly>, + provided: readonly string[] = [], + ): Facts => + (key) => { + const value = + typeof key === "string" + ? values[key] + : provided.some( + (id) => JSON.stringify(key) === JSON.stringify(artefactKey(id)), + ) + ? true + : undefined; + + return value === undefined + ? { found: false } + : { found: true, value: { kind: "boolean", value } }; + }; + +/** A facts port that knows nothing, so every condition reading it is indeterminate. */ +export const noFacts: Facts = () => ({ found: false }); diff --git a/packages/core/graph/domain/src/test-support/models.ts b/packages/core/graph/domain/src/test-support/models.ts new file mode 100644 index 0000000..def8c64 --- /dev/null +++ b/packages/core/graph/domain/src/test-support/models.ts @@ -0,0 +1,245 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { elementStatusKey } from "../facts.ts"; +import { fails, holds } from "./conditions.ts"; + +const team = [{ id: "team", label: "Team", kind: "role" }] as const; + +/** A chain a, b, c. */ +export const chain = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "team" }, + { kind: "activity", id: "b", label: "B", actor: "team" }, + { kind: "activity", id: "c", label: "C", actor: "team" }, + ], + transitions: [ + { id: "ab", source: "a", target: "b" }, + { id: "bc", source: "b", target: "c" }, + ], + fragments: [], + projections: [], +}); + +/** A diamond: a, then b and c, then d. */ +export const diamond = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "team" }, + { kind: "activity", id: "b", label: "B", actor: "team" }, + { kind: "activity", id: "c", label: "C", actor: "team" }, + { kind: "activity", id: "d", label: "D", actor: "team" }, + ], + transitions: [ + { id: "ab", source: "a", target: "b" }, + { id: "ac", source: "a", target: "c" }, + { id: "bd", source: "b", target: "d" }, + { id: "cd", source: "c", target: "d" }, + ], + fragments: [], + projections: [], +}); + +/** Write, review and publish, where a failed review (decision) sends the work back to write. */ +export const retryLoop = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "write", label: "Write", actor: "team" }, + { kind: "decision", id: "review", label: "Review passed?", actor: "team" }, + { kind: "activity", id: "publish", label: "Publish", actor: "team" }, + ], + transitions: [ + { id: "to-review", source: "write", target: "review" }, + { + id: "pass", + source: "review", + target: "publish", + condition: holds("passed"), + }, + { + id: "retry", + source: "review", + target: "write", + condition: fails("passed"), + }, + ], + fragments: [], + projections: [], +}); + +/** Two activities that wait on each other with no decision between them. */ +export const plainCycle = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "x", label: "X", actor: "team" }, + { kind: "activity", id: "y", label: "Y", actor: "team" }, + ], + transitions: [ + { id: "xy", source: "x", target: "y" }, + { id: "yx", source: "y", target: "x" }, + ], + fragments: [], + projections: [], +}); + +/** Triage, then a decision on `approved` into accept or reject, then a merge activity both reach. */ +export const approval = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "triage", label: "Triage", actor: "team" }, + { kind: "decision", id: "choose", label: "Approved?", actor: "team" }, + { kind: "activity", id: "accept", label: "Accept", actor: "team" }, + { kind: "activity", id: "reject", label: "Reject", actor: "team" }, + { kind: "activity", id: "notify", label: "Notify", actor: "team" }, + { kind: "activity", id: "close", label: "Close", actor: "team" }, + ], + transitions: [ + { id: "t-choose", source: "triage", target: "choose" }, + { + id: "t-accept", + source: "choose", + target: "accept", + condition: holds("approved"), + }, + { + id: "t-reject", + source: "choose", + target: "reject", + condition: fails("approved"), + }, + { id: "t-notify", source: "accept", target: "notify" }, + { id: "t-close-a", source: "notify", target: "close" }, + { id: "t-close-r", source: "reject", target: "close" }, + ], + fragments: [], + projections: [], +}); + +/** A fork into b and c, a join, then e. Neither branch is conditional. */ +export const forkJoin = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "team" }, + { kind: "pseudo", id: "fork", role: "fork" }, + { kind: "activity", id: "b", label: "B", actor: "team" }, + { kind: "activity", id: "c", label: "C", actor: "team" }, + { kind: "pseudo", id: "join", role: "join" }, + { kind: "activity", id: "e", label: "E", actor: "team" }, + ], + transitions: [ + { id: "a-fork", source: "a", target: "fork" }, + { id: "fork-b", source: "fork", target: "b" }, + { id: "fork-c", source: "fork", target: "c" }, + { id: "b-join", source: "b", target: "join" }, + { id: "c-join", source: "c", target: "join" }, + { id: "join-e", source: "join", target: "e" }, + ], + fragments: [], + projections: [], +}); + +/** A decision into b or c, both of which reach a join, then e. */ +export const branchJoin = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "a", label: "A", actor: "team" }, + { kind: "decision", id: "pick", label: "Fast path?", actor: "team" }, + { kind: "activity", id: "b", label: "B", actor: "team" }, + { kind: "activity", id: "c", label: "C", actor: "team" }, + { kind: "pseudo", id: "join", role: "join" }, + { kind: "activity", id: "e", label: "E", actor: "team" }, + ], + transitions: [ + { id: "a-pick", source: "a", target: "pick" }, + { id: "pick-b", source: "pick", target: "b", condition: holds("fast") }, + { id: "pick-c", source: "pick", target: "c", condition: fails("fast") }, + { id: "b-join", source: "b", target: "join" }, + { id: "c-join", source: "c", target: "join" }, + { id: "join-e", source: "join", target: "e" }, + ], + fragments: [], + projections: [], +}); + +/** Build needs a spec nobody produces and makes a bundle; ship consumes the bundle and makes the release deliverable. No transitions: control is the artefacts alone. */ +export const pipeline = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [ + { id: "spec", name: "Specification" }, + { id: "bundle", name: "Bundle" }, + { id: "release", name: "Release", deliverable: true }, + ], + elements: [ + { + kind: "activity", + id: "build", + label: "Build", + actor: "team", + consumes: ["spec"], + produces: ["bundle"], + }, + { + kind: "activity", + id: "ship", + label: "Ship", + actor: "team", + consumes: ["bundle"], + produces: ["release"], + }, + ], + transitions: [], + fragments: [], + projections: [], +}); + +/** A decision whose branch reads another element's run state through the reserved key. */ +export const reviewGate = defineModel({ + participants: team, + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "audit", label: "Audit", actor: "team" }, + { kind: "activity", id: "start", label: "Start", actor: "team" }, + { kind: "decision", id: "gate", label: "Audit done?", actor: "team" }, + { kind: "activity", id: "proceed", label: "Proceed", actor: "team" }, + ], + transitions: [ + { id: "start-gate", source: "start", target: "gate" }, + { + id: "gate-proceed", + source: "gate", + target: "proceed", + condition: { + kind: "textCompare", + op: "equals", + left: { kind: "reference", key: elementStatusKey("audit") }, + right: { kind: "textLiteral", value: "done" }, + }, + }, + ], + fragments: [], + projections: [], +}); diff --git a/packages/core/graph/domain/stryker.config.ts b/packages/core/graph/domain/stryker.config.ts new file mode 100644 index 0000000..6429b27 --- /dev/null +++ b/packages/core/graph/domain/stryker.config.ts @@ -0,0 +1,3 @@ +import { packageStrykerConfig } from "../../../../stryker.shared.ts"; + +export default packageStrykerConfig(); diff --git a/packages/core/graph/domain/test/package.smoke.test.ts b/packages/core/graph/domain/test/package.smoke.test.ts new file mode 100644 index 0000000..48e7371 --- /dev/null +++ b/packages/core/graph/domain/test/package.smoke.test.ts @@ -0,0 +1,27 @@ +import { defineModel } from "@exadev/process-graph-schema"; +import { expect, test } from "vitest"; +// Deliberately imports the built dist/, not src/: this is the check that catches a wrong exports map or a build misconfiguration that a unit test importing straight from src/ never touches. +import { deriveStatuses, readySet } from "../dist/index.js"; + +test("the built package derives status and the ready set", () => { + const model = defineModel({ + participants: [{ id: "team", label: "Team", kind: "role" }], + events: [], + actions: [], + artefacts: [], + elements: [ + { kind: "activity", id: "build", label: "Build", actor: "team" }, + { kind: "activity", id: "test", label: "Test", actor: "team" }, + ], + transitions: [{ id: "t1", source: "build", target: "test" }], + fragments: [], + projections: [], + }); + const facts = () => ({ found: false }) as const; + + expect([...deriveStatuses(model, { build: "done" }, facts)]).toEqual([ + ["build", "done"], + ["test", "ready"], + ]); + expect(readySet(model, {}, facts)).toEqual(["build"]); +}); diff --git a/packages/core/graph/domain/tsconfig.build.json b/packages/core/graph/domain/tsconfig.build.json new file mode 100644 index 0000000..39f1470 --- /dev/null +++ b/packages/core/graph/domain/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "noEmit": false, + "declaration": true, + "declarationMap": false, + "rootDir": "src", + "outDir": "dist", + "rewriteRelativeImportExtensions": true + }, + "references": [ + { + "path": "../dag/tsconfig.build.json" + }, + { + "path": "../schema/tsconfig.build.json" + } + ] +} diff --git a/packages/core/graph/domain/tsconfig.json b/packages/core/graph/domain/tsconfig.json new file mode 100644 index 0000000..5645111 --- /dev/null +++ b/packages/core/graph/domain/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/test-support/**"] +} diff --git a/packages/core/graph/domain/tsconfig.node.json b/packages/core/graph/domain/tsconfig.node.json new file mode 100644 index 0000000..8a460f4 --- /dev/null +++ b/packages/core/graph/domain/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "*.config.ts"] +} diff --git a/packages/core/graph/domain/vitest.config.ts b/packages/core/graph/domain/vitest.config.ts new file mode 100644 index 0000000..01b9391 --- /dev/null +++ b/packages/core/graph/domain/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + projects: [ + { + extends: true, + test: { name: "unit", include: ["src/**/*.test.ts"] }, + }, + { + extends: true, + test: { name: "smoke", include: ["test/package.smoke.test.ts"] }, + }, + ], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cf9bca..6e2fdf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,6 +296,49 @@ importers: specifier: 4.1.11 version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/domain: + dependencies: + '@exadev/process-graph-dag': + specifier: 0.0.0 + version: link:../dag + '@exadev/process-graph-schema': + specifier: 0.0.0 + version: link:../schema + trilean: + specifier: 1.7.2 + version: 1.7.2 + devDependencies: + '@arethetypeswrong/cli': + specifier: 0.18.5 + version: 0.18.5 + '@stryker-mutator/core': + specifier: 10.0.0 + version: 10.0.0(@types/node@26.6.4) + '@stryker-mutator/typescript-checker': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(typescript@6.0.3) + '@stryker-mutator/vitest-runner': + specifier: 10.0.0 + version: 10.0.0(@stryker-mutator/core@10.0.0(@types/node@26.6.4))(vitest@4.1.11) + '@types/node': + specifier: 26.6.4 + version: 26.6.4 + '@vitest/coverage-v8': + specifier: 4.1.11 + version: 4.1.11(vitest@4.1.11) + eslint: + specifier: 10.12.0 + version: 10.12.0(jiti@2.7.0)(supports-color@7.2.0) + publint: + specifier: 0.3.25 + version: 0.3.25 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@8.3.2(@types/node@26.6.4)(jiti@2.7.0)(yaml@2.9.1)) + packages/core/graph/projection: dependencies: '@exadev/process-graph-schema': diff --git a/tsconfig.build.json b/tsconfig.build.json index ccc5306..565b6e2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -9,6 +9,9 @@ }, { "path": "packages/core/graph/projection/tsconfig.build.json" + }, + { + "path": "packages/core/graph/domain/tsconfig.build.json" } ] }