Critical fixes: - C1: Create standalone ADR-006 file (edge type consistency), extract from open-questions.md inline content - C2: Convert CallResult from plain interface to TypeBox schema, aligning with 'TypeBox as single source of truth' constraint - C3: Add fromJSON() cycle detection specification - enforce ADR-002 DAG invariant even on deserialized input - C4: Rewrite consumer-integration.md Phase 4 to use ADR-005 event-append pattern instead of direct signal mutation - C5: Fix operator precedence bug in consumer-integration.md (missing parentheses around OR condition) Warnings addressed: - W1: Fix immutability claim - operation graph is 'conventionally immutable', not prevented by API - W2: Add EventLogProjection to reactive exports map - W3: Add CallResult/CallResultSchema to schema exports map - W4: Fix reactive-execution.md Level 1 error handling to use event-append pattern instead of direct signal mutation - W5: Remove duplicate dataFlow inference description in schema.md - W6: Clarify ADR-006 project context (flowgraph vs taskgraph) Suggestions implemented: - S1: Add 'reviewed' document lifecycle status between draft/stable, update all docs to reviewed status - S2: Add carve-out note for analysis result types in schema.md constraints (they are ephemeral, not serialized) - S3: Add isComplete() and getAggregateStatus() convenience methods to WorkflowReactiveRoot specification
346 lines
18 KiB
Markdown
346 lines
18 KiB
Markdown
---
|
||
status: reviewed
|
||
last_updated: 2026-05-22
|
||
---
|
||
|
||
# Analysis Functions
|
||
|
||
Standalone composable functions for type-compatibility checking, execution ordering, and precondition validation.
|
||
|
||
## Overview
|
||
|
||
Analysis functions are pure, composable functions that operate on a `FlowGraph` instance. They follow the same pattern as taskgraph: standalone functions (not methods on the class) that take a graph as input and return structured results.
|
||
|
||
The analysis layer provides:
|
||
|
||
- **Type compatibility** — can operation A's output feed into operation B's input?
|
||
- **Execution ordering** — what's a valid topological order for a set of operations?
|
||
- **Precondition validation** — are all required inputs available before a step starts?
|
||
- **Reachability** — which operations can be reached from a given starting point?
|
||
- **Template validation** — does a workflow template follow a valid path through the operation graph?
|
||
|
||
All analysis functions are pure: they don't mutate the graph, they don't depend on external state, and they return structured results (not throw on failure). This makes them testable, composable, and suitable for both synchronous and async use.
|
||
|
||
## Type Compatibility
|
||
|
||
### Compatibility Contract
|
||
|
||
The `typeCompat` function defines a clear contract for what each result means:
|
||
|
||
| Result | Meaning | What the consumer should do |
|
||
|--------|---------|-----------------------------|
|
||
| `{ compatible: true }` | Output schema is a subtype of input schema | Allow the edge; data can flow from source to target without transformation |
|
||
| `{ compatible: true, detail }` | Compatible with notes | Allow the edge; the `detail` string describes why (e.g., "output has extra fields beyond input requirements") |
|
||
| `{ compatible: false, mismatches }` | Structural incompatibility | Reject the edge or add a transformation step; `mismatches` lists specific field-level problems |
|
||
| No edge at all | Unknown compatibility (one or both schemas are `Type.Unknown()`) | Neither compatible nor incompatible; no edge is created |
|
||
|
||
### Depth of Compatibility Checking
|
||
|
||
`typeCompat` performs **deep recursive structural comparison**:
|
||
|
||
1. **Top-level fields** — all required fields in `inputSchema` must be present in `outputSchema`
|
||
2. **Nested objects** — recursively compared. If `inputSchema` requires `{ address: { city: string } }`, `outputSchema` providing `{ address: { city: string, zip: string } }` is compatible (output is a superset)
|
||
3. **Arrays** — element types are compared. If `inputSchema` requires `string[]`, `outputSchema` providing `(string | number)[]` is **not** compatible (output could produce non-string elements)
|
||
4. **Optional fields** — if `inputSchema` marks a field as optional (`Type.Optional()`), it's not required in `outputSchema`. If `outputSchema` omits it, compatibility is still `true`.
|
||
5. **Union types** — if `inputSchema` accepts `string | number`, `outputSchema` providing just `string` is compatible (string is a subtype of string | number). The reverse (input requires `string`, output provides `string | number`) is **not** compatible.
|
||
|
||
The `mismatches` array provides field-level diagnostics for incompatible results:
|
||
|
||
```typescript
|
||
interface TypeMismatch {
|
||
path: string; // JSON path to the mismatched field (e.g., "/address/city")
|
||
expected: string; // What input requires (e.g., "string")
|
||
actual: string; // What output provides (e.g., "number")
|
||
}
|
||
```
|
||
|
||
### Compatibility Rules Summary
|
||
|
||
| Output \ Input | Exact match | Superset of input | Subset of input | Unknown |
|
||
|---------------|-------------|-------------------|-----------------|---------|
|
||
| Exact match | ✅ compatible | ✅ compatible | ❌ incompatible | No edge |
|
||
| Superset | ✅ compatible | ✅ compatible | ❌ incompatible | No edge |
|
||
| Subset | ❌ incompatible | ❌ incompatible | Depends on which fields | No edge |
|
||
| Unknown | No edge | No edge | No edge | No edge |
|
||
|
||
### `typeCompat(outputSchema, inputSchema)`
|
||
|
||
```typescript
|
||
function typeCompat(
|
||
outputSchema: TSchema,
|
||
inputSchema: TSchema,
|
||
): TypeCompatResult
|
||
|
||
interface TypeCompatResult {
|
||
compatible: boolean;
|
||
detail?: string;
|
||
mismatches?: TypeMismatch[];
|
||
}
|
||
|
||
interface TypeMismatch {
|
||
path: string; // JSON path to the mismatched field
|
||
expected: string; // What the input schema requires
|
||
actual: string; // What the output schema provides
|
||
}
|
||
```
|
||
|
||
Compares two TypeBox schemas and determines if the output schema is compatible with the input schema. Returns a structured result with details about mismatches.
|
||
|
||
### Compatibility rules
|
||
|
||
The analysis is **structural**, not semantic. It checks whether the output shape can satisfy the input shape:
|
||
|
||
1. **Exact match** — `outputSchema` and `inputSchema` are structurally identical → `compatible: true`
|
||
2. **Output is superset** — output has all fields that input requires, plus extras → `compatible: true` (output is a subtype of input, meaning input accepts output)
|
||
3. **Output is subset** — output is missing fields that input requires → `compatible: false`, with `mismatches` listing the missing fields
|
||
4. **Type mismatch** — output field type doesn't match input field type → `compatible: false`, with `mismatches` listing the type differences
|
||
5. **Unknown passthrough** — if either schema is `Type.Unknown()`, compatibility is unknown → no edge is created (not incompatible, just unresolvable)
|
||
|
||
### Subtype checking
|
||
|
||
The key insight: **output must be a subtype of input** for compatibility. This means:
|
||
|
||
- If input expects `{ name: string, age: number }`, output must provide at least those fields
|
||
- If input expects `string`, output providing `string | number` is **not** compatible (it could produce a number)
|
||
- If input expects `string | number`, output providing `string` **is** compatible (string is a subset of string|number)
|
||
|
||
This follows standard type theory: the output must be at least as specific as what the input requires.
|
||
|
||
### `buildTypeEdges(graph)`
|
||
|
||
```typescript
|
||
function buildTypeEdges(graph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>): void
|
||
```
|
||
|
||
Populates the operation graph with type-compatibility edges. For each pair of nodes (A, B), calls `typeCompat(A.outputSchema, B.inputSchema)` and adds an edge with the result.
|
||
|
||
This is called automatically by `FlowGraph.fromSpecs()`. It can also be called manually after adding operations incrementally.
|
||
|
||
### Edge attributes from type compatibility
|
||
|
||
A type-compatibility edge carries:
|
||
|
||
```typescript
|
||
{
|
||
edgeType: "typed",
|
||
compatible: boolean, // true if output feeds into input
|
||
detail?: string, // "classify.output is compatible with enrich.input"
|
||
mismatches?: TypeMismatch[] // specific field-level mismatches (if incompatible)
|
||
}
|
||
```
|
||
|
||
## Execution Ordering
|
||
|
||
### `topologicalOrder(graph)`
|
||
|
||
```typescript
|
||
function topologicalOrder(graph: FlowGraph): string[]
|
||
```
|
||
|
||
Returns node keys in topological order (prerequisites before dependents). Uses `graphology-dag`'s `topologicalSort` algorithm.
|
||
|
||
Throws `CycleError` if the graph contains cycles, with `cycles` populated by `findCycles()`.
|
||
|
||
### `parallelGroups(graph)`
|
||
|
||
```typescript
|
||
function parallelGroups(graph: FlowGraph): string[][]
|
||
```
|
||
|
||
Returns groups of nodes that can execute in parallel. Each group is an array of node keys. Groups are ordered by dependency depth:
|
||
|
||
- Group 0: nodes with no prerequisites (roots)
|
||
- Group 1: nodes whose only prerequisites are in Group 0
|
||
- Group N: nodes whose prerequisites are all in Groups 0 through N-1
|
||
|
||
This is useful for the hub coordinator to determine max parallelism: all nodes in a group can start simultaneously.
|
||
|
||
### `criticalPath(graph)`
|
||
|
||
```typescript
|
||
function criticalPath(graph: FlowGraph): string[]
|
||
```
|
||
|
||
Returns the longest path through the DAG, which represents the sequence of operations that determines the minimum total execution time. Useful for identifying bottlenecks.
|
||
|
||
## Precondition Validation
|
||
|
||
### `validatePreconditions(graph)`
|
||
|
||
```typescript
|
||
function validatePreconditions(
|
||
graph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>
|
||
): ValidationError[]
|
||
```
|
||
|
||
For each node in the operation graph, checks that all required input fields are provided by at least one predecessor's output. Returns an array of `ValidationError` objects (never throws).
|
||
|
||
A "missing precondition" occurs when a node's input requires a field that no predecessor's output provides. This is a stronger check than type compatibility — it verifies that a valid execution path exists through the graph.
|
||
|
||
### `validateTemplate(template, operationGraph)`
|
||
|
||
```typescript
|
||
function validateTemplate(
|
||
template: UNode,
|
||
operationGraph: FlowGraph<OperationNodeAttrs, OperationEdgeAttrs>,
|
||
): ValidationError[]
|
||
```
|
||
|
||
Validates a workflow template against an operation graph:
|
||
|
||
1. **All operations exist** — every `<Operation name="X">` has a matching node in the operation graph
|
||
2. **No cycles** — the rendered DAG has no cycles
|
||
3. **Type compatibility** — sequential operations have compatible type edges (or no incompatible edge)
|
||
4. **Reachability** — all operations are reachable from the start
|
||
5. **No orphan nodes** — every operation has at least one incoming or outgoing edge (unless it's a single-operation template)
|
||
|
||
Returns an array of `ValidationError` objects. Template validation is advisory — it can produce warnings (e.g., "operation not in registry") and errors (e.g., "cycle detected").
|
||
|
||
## Reachability
|
||
|
||
### `reachableFrom(graph, nodeIds)`
|
||
|
||
```typescript
|
||
function reachableFrom(graph: FlowGraph, nodeIds: string[]): Set<string>
|
||
```
|
||
|
||
Returns all node keys reachable from the given starting nodes via directed edges. Useful for:
|
||
- Determining which operations a coordinator can reach from a starting operation
|
||
- Computing the abort cascade scope for a given call
|
||
- Finding all operations affected by a change to a particular operation
|
||
|
||
### `ancestors(graph, nodeId)`
|
||
|
||
```typescript
|
||
function ancestors(graph: FlowGraph, nodeId: string): string[]
|
||
```
|
||
|
||
Returns all ancestors of a node (nodes reachable via incoming edges). Useful for:
|
||
- Finding which operations must complete before a given operation can start
|
||
- Computing depth-from-roots for execution priority
|
||
|
||
### `descendants(graph, nodeId)`
|
||
|
||
```typescript
|
||
function descendants(graph: FlowGraph, nodeId: string): string[]
|
||
```
|
||
|
||
Returns all descendants of a node (nodes reachable via outgoing edges). Useful for:
|
||
- Finding all calls that would be affected by aborting a given call
|
||
- Computing the scope of a failure cascade
|
||
|
||
## Graph-Level Validation
|
||
|
||
### `validateGraph(graph)`
|
||
|
||
```typescript
|
||
function validateGraph(graph: FlowGraph): AnyValidationError[]
|
||
```
|
||
|
||
Runs all validation checks:
|
||
|
||
1. **Schema validation** — node attributes match `OperationNodeAttrs` or `CallNodeAttrs` schema
|
||
2. **Graph invariants** — no cycles, no dangling edges, no self-loops
|
||
3. **Orphan detection** — nodes with no edges (warning, not error)
|
||
|
||
Returns an array of `AnyValidationError` objects, which is a union type:
|
||
|
||
```typescript
|
||
type AnyValidationError = ValidationError | GraphValidationError;
|
||
```
|
||
|
||
Matching taskgraph's pattern, this function never throws — it collects all issues and returns them.
|
||
|
||
## Standalone Function Pattern
|
||
|
||
All analysis functions are standalone (not methods on `FlowGraph`). They take a `FlowGraph` instance as their first argument and return structured results. This follows taskgraph's pattern:
|
||
|
||
```typescript
|
||
// Standalone functions
|
||
import { topologicalOrder, hasCycles, typeCompat } from "@alkdev/flowgraph/analysis";
|
||
|
||
const order = topologicalOrder(graph);
|
||
const cycles = hasCycles(graph);
|
||
const result = typeCompat(outputSchema, inputSchema);
|
||
```
|
||
|
||
The `FlowGraph` class exposes convenience methods that delegate to these standalone functions:
|
||
|
||
```typescript
|
||
class FlowGraph {
|
||
topologicalOrder(): string[] { return _topologicalOrder(this._graph); }
|
||
hasCycles(): boolean { return _hasCycles(this._graph); }
|
||
validate(): AnyValidationError[] { return _validate(this._graph); }
|
||
}
|
||
```
|
||
|
||
This pattern enables:
|
||
- **Tree-shaking** — consumers only import the analysis functions they use
|
||
- **Testing** — standalone functions are easier to test in isolation
|
||
- **Composition** — consumers can chain analysis functions without creating intermediate `FlowGraph` instances
|
||
|
||
## Performance Characteristics
|
||
|
||
Analysis functions are pure and operate on the graph in memory. Their complexity is:
|
||
|
||
| Function | Complexity | Notes |
|
||
|----------|-----------|-------|
|
||
| `topologicalOrder()` | O(V + E) | Linear in nodes + edges. Single traversal. |
|
||
| `parallelGroups()` | O(V + E) | Same as topological sort. One pass. |
|
||
| `criticalPath()` | O(V + E) | Longest path in DAG. Single traversal with path tracking. |
|
||
| `reachableFrom()` | O(V + E) | BFS/DFS from starting nodes. |
|
||
| `ancestors()` | O(V + E) | Backward traversal from target. |
|
||
| `descendants()` | O(V + E) | Forward traversal from target. |
|
||
| `hasCycles()` | O(V + E) | DFS-based cycle detection. Always `false` after validated construction. |
|
||
| `findCycles()` | O(V + E) | Johnson's algorithm for finding all elementary cycles. |
|
||
| `typeCompat()` | O(depth) | Depends on schema depth. Schemas are typically shallow (5-10 fields). Fast for realistic schemas. |
|
||
| `buildTypeEdges()` | O(V²) | Pairwise comparison of all operations. For 50 operations: 2,500 comparisons. For 200: 40,000. Each comparison is `O(depth)`. |
|
||
| `validateTemplate()` | O(V + E) | Template traversal plus DAG validation. |
|
||
| `validatePreconditions()` | O(V × E) | For each node, check all predecessors. |
|
||
| `validateGraph()` | O(V + E) | Cycle detection + edge validation + orphan detection. |
|
||
|
||
### Practical Performance
|
||
|
||
For expected graph sizes (10-200 nodes):
|
||
|
||
- `buildTypeEdges()`: 0.5-5ms for 50 operations, 5-50ms for 200 operations
|
||
- `topologicalOrder()`: <1ms for any realistic graph
|
||
- `typeCompat()`: <0.01ms per comparison
|
||
- All query functions: <1ms for any realistic graph
|
||
|
||
These are in-memory operations with no I/O. The dominant cost is `buildTypeEdges()` which scales quadratically with the number of operations. For very large registries (>500 operations), consider lazy edge construction or caching.
|
||
|
||
### Optimization Opportunities
|
||
|
||
1. **Lazy edge construction** — `buildTypeEdges()` currently compares all pairs. For large registries, edges could be computed on demand: when `typeCompat(A, B)` is queried, compute and cache the result. This trades startup time for query-time cost.
|
||
|
||
2. **Type compatibility caching** — `typeCompat()` results could be cached by schema hash. Identical schemas always produce the same result. This helps when the same operation appears in multiple templates.
|
||
|
||
3. **Incremental graph updates** — when a single operation is added to the registry, only compute edges for the new node (O(V) instead of O(V²)).
|
||
|
||
4. **Parallel group scheduling** — `parallelGroups()` is useful for the hub coordinator to determine max parallelism. An optional `maxConcurrency` parameter could be added to limit group sizes for realistic scheduling.
|
||
|
||
## Constraints
|
||
|
||
- **Analysis functions are pure** — they don't mutate the graph, don't depend on external state, and don't throw on validation failures (they return error arrays)
|
||
- **Type compatibility is structural, not semantic** — `typeCompat()` checks schema shapes, not whether the data makes sense. "Age as number" is compatible with "count as number" even though they're semantically different.
|
||
- **Template validation is advisory** — warnings are not errors. A template with an unknown operation is a warning, not a validation failure (the operation might be added to the registry later).
|
||
- **Analysis functions work on the underlying `DirectedGraph`** — they're thin wrappers around graphology and graphology-dag functions, following the same pattern as taskgraph
|
||
- **`topologicalOrder()` throws on cycles** — unlike `validateGraph()` which returns errors, `topologicalOrder()` throws `CycleError` because it cannot produce a valid ordering from a cyclic graph
|
||
|
||
## Open Questions
|
||
|
||
1. ~~**How deep should `typeCompat` check?**~~ **Resolved (OQ-002/ADR-005)**: Type compatibility checking performs deep recursive structural comparison. The `TypeCompatResult` includes `mismatches?: TypeMismatch[]` with field-level diagnostics for incompatible edges. Type checking only applies to state-transfer edges (where `dataFlow: true` on `TemplateEdgeAttrs`). Temporal-only edges bypass type checking entirely. Remaining detail decisions (recursive depth limits, unknown/union type handling) are implementation concerns, not architecture decisions.
|
||
|
||
2. ~~**Should `validateTemplate` check runtime preconditions?**~~ **Resolved (OQ-027)**: Explicitly out of scope. `validateTemplate` only checks structural validity and type compatibility. Runtime preconditions (e.g., "operation B requires an API key that operation A doesn't have access to") belong to the access control layer, not the static analysis layer. This is a deliberate scope boundary, not a design gap.
|
||
|
||
3. ~~**Should analysis functions be async?**~~ **Resolved (OQ-024)**: No — synchronous is sufficient for current scale. Expected graph sizes (10-200 nodes) are well within synchronous processing limits. Making functions async would add API complexity (Promise return types, async/await boilerplate) for no current benefit. If large graphs become common, async variants can be added alongside the synchronous ones.
|
||
|
||
4. ~~**Should `parallelGroups` account for resource constraints?**~~ **Resolved (OQ-019)**: No for v1 — `parallelGroups()` returns theoretical maximum parallelism. Adding resource constraints would conflate structural analysis with scheduling policy. The `maxConcurrency` prop on `Parallel` is a runtime scheduling concern handled by the reactive engine, not a structural analysis concern. If consumers need resource-aware scheduling, they can post-process `parallelGroups()` output with their own constraints. An optional `maxConcurrency` parameter can be added in v2 as a convenience, but the core analysis function stays pure.
|
||
|
||
## References
|
||
|
||
- Schema: [schema.md](schema.md) — `TypeCompatResult`, `TypeMismatch`, `ValidationError`
|
||
- Error handling: [error-handling.md](error-handling.md) — `CycleError`, `TypeIncompatError`
|
||
- Taskgraph analysis pattern: `@alkdev/taskgraph_ts/src/analysis/`
|
||
- TypeBox Value utilities: `@alkdev/typebox/value` |