A harness is a graph, and the graph can run in three shapes. The first two are
the graph's structure and run inside a single FlowRun; the third is time
and lives one layer up.
The default. Nodes run in order; each node's output is the next node's input.
[load-task] -> [start-env] -> [step-1] -> [verify]
const path = createFlowPath({ id, nodes, extractors, sink });
const run = await path.run(input);Under the hood this is a modelgraph with no connections — a straight line.
Pass the harness as nodes + edges. The edges ARE the connections: an edge
from -> to feeds to with from's output. Several edges out of one node is a
branch (its feeders run together); several edges into one node is a merge
(the merge node receives the ordered list of its feeders' outputs).
/-> [Cheap Draft] -\
[Context] -> ( branch ) ( merge ) -> [Select] -> [Verify]
\-> [Careful Draft] -/
const graph = createFlowGraph({ id, nodes, edges, extractors, sink });
const run = await graph.run(input);The result is the same FlowRun as a linear path — path, trace, signals,
accepted — so printFlowGraph, scorePath (Φ = Q / C), comparePaths, and
findBottleneck all work unchanged. The run's path is the actual executed
order (a merge node runs only after every feeder has finished).
This is the real point of branch/merge: which branch wins is a property of the
graph, not of any one node. See ../labs/08-nonlinear-harness
— with no hint the cheap-but-wrong branch wins on Φ and the run is rejected;
once feedback supplies a hint the careful branch wins the merge and the run is
accepted. Same nodes, different flow.
Repeating a run is time, not graph structure, so it does not belong in the
core graph. The core rejects cycles. A bounded retry is composed on top with
runWithFeedback, which routes the failed run's observation/error back into a
new input:
const loop = await runWithFeedback({
path: graph, // a FlowPath or a FlowGraph
input,
retry: (lastRun, attempt) => /* build the next input, or undefined to stop */,
maxAttempts: 3,
});Keeping loops here keeps the primitive layer (modelgraph) barebones: it understands a single pass through a branch/merge graph and nothing about iteration, convergence, or saturation.
Once a harness is data (nodes + edges) rather than hand-wired control flow, the structure itself becomes something you can change and measure:
- add a branch (try two strategies, keep the best by Φ)
- add a merge (combine candidates)
- add a node (e.g. a cache /
reuse-observationstep — see the off-by-default seam in../labs/07-terminal-bench-live/lib/flow-build.ts) - wrap in a bounded loop
Each move is an edit to the spec, and each produces a FlowRun you can score.
This document describes the substrate only. An automated harness optimizer —
a catalog of structural moves (add cache / add branch / add retry), a Φ-ranked
search over candidate specs, and held-out validation so a move has to generalize
— is the next phase. See 08-self-improving-harness.md
for what that loop is, how it maps onto these primitives against a real benchmark
(Terminal-Bench / Harbor), and the honest constraints that keep it legitimate.