@spendgraph/harness 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +87 -160
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +91 -0
  4. package/dist/cascade/index.d.ts +3 -0
  5. package/dist/cascade/index.js +2 -0
  6. package/dist/cascade/tier.d.ts +31 -0
  7. package/dist/cascade/tier.js +53 -0
  8. package/dist/cascade/types.d.ts +98 -0
  9. package/dist/chain/chain.d.ts +9 -0
  10. package/dist/chain/chain.js +58 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +33 -0
  13. package/dist/chain/index.d.ts +3 -0
  14. package/dist/chain/index.js +2 -0
  15. package/dist/chain/step.d.ts +9 -0
  16. package/dist/chain/step.js +14 -0
  17. package/dist/chain/types.d.ts +50 -0
  18. package/dist/index.d.ts +16 -9
  19. package/dist/index.js +8 -7
  20. package/dist/loop/act.d.ts +21 -0
  21. package/dist/loop/act.js +43 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +59 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +41 -0
  26. package/dist/loop/index.d.ts +6 -0
  27. package/dist/loop/index.js +5 -0
  28. package/dist/loop/loop.d.ts +13 -0
  29. package/dist/loop/loop.js +120 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +17 -0
  32. package/dist/loop/types.d.ts +140 -0
  33. package/dist/loop/types.js +1 -0
  34. package/dist/orchestrate/index.d.ts +4 -0
  35. package/dist/orchestrate/index.js +3 -0
  36. package/dist/orchestrate/orchestrate.d.ts +12 -0
  37. package/dist/orchestrate/orchestrate.js +68 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +38 -0
  40. package/dist/orchestrate/types.d.ts +75 -0
  41. package/dist/orchestrate/types.js +1 -0
  42. package/dist/orchestrate/work.d.ts +13 -0
  43. package/dist/orchestrate/work.js +35 -0
  44. package/dist/parallel/index.d.ts +4 -0
  45. package/dist/parallel/index.js +3 -0
  46. package/dist/parallel/merge.d.ts +26 -0
  47. package/dist/parallel/merge.js +52 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +66 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +45 -0
  52. package/dist/parallel/types.d.ts +61 -0
  53. package/dist/parallel/types.js +1 -0
  54. package/dist/refine/attempt.d.ts +22 -0
  55. package/dist/refine/attempt.js +38 -0
  56. package/dist/refine/index.d.ts +4 -0
  57. package/dist/refine/index.js +3 -0
  58. package/dist/refine/judge.d.ts +15 -0
  59. package/dist/refine/judge.js +25 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +67 -0
  62. package/dist/refine/types.d.ts +90 -0
  63. package/dist/refine/types.js +1 -0
  64. package/dist/route/classify.d.ts +52 -0
  65. package/dist/route/classify.js +57 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +33 -0
  68. package/dist/route/index.d.ts +3 -0
  69. package/dist/route/index.js +2 -0
  70. package/dist/route/route.d.ts +11 -0
  71. package/dist/route/route.js +31 -0
  72. package/dist/route/types.d.ts +53 -0
  73. package/dist/route/types.js +1 -0
  74. package/dist/stream/graph.d.ts +9 -0
  75. package/dist/stream/graph.js +16 -0
  76. package/dist/stream/index.d.ts +2 -0
  77. package/dist/stream/index.js +1 -0
  78. package/dist/stream/stream.d.ts +19 -0
  79. package/dist/stream/stream.js +65 -0
  80. package/dist/stream/types.d.ts +31 -0
  81. package/dist/stream/types.js +1 -0
  82. package/docs/README.md +41 -0
  83. package/docs/cascade.md +165 -0
  84. package/docs/chain.md +134 -0
  85. package/docs/loop.md +318 -0
  86. package/docs/orchestrate.md +128 -0
  87. package/docs/parallel.md +139 -0
  88. package/docs/refine.md +206 -0
  89. package/docs/route.md +212 -0
  90. package/package.json +18 -14
  91. package/bin/harness.mjs +0 -76
  92. package/dist/core/budget.d.ts +0 -46
  93. package/dist/core/budget.js +0 -72
  94. package/dist/core/cache.d.ts +0 -119
  95. package/dist/core/cache.js +0 -206
  96. package/dist/core/client.d.ts +0 -33
  97. package/dist/core/client.js +0 -98
  98. package/dist/core/errors.d.ts +0 -22
  99. package/dist/core/errors.js +0 -28
  100. package/dist/core/index.d.ts +0 -5
  101. package/dist/core/index.js +0 -5
  102. package/dist/core/pull.d.ts +0 -17
  103. package/dist/core/pull.js +0 -38
  104. package/dist/core/schema/serialize.d.ts +0 -12
  105. package/dist/core/schema/serialize.js +0 -42
  106. package/dist/core/schema/types.d.ts +0 -58
  107. package/dist/core/schema/validate.d.ts +0 -28
  108. package/dist/core/schema/validate.js +0 -98
  109. package/dist/harness.d.ts +0 -128
  110. package/dist/harness.js +0 -187
  111. package/dist/prompt/codegen.d.ts +0 -37
  112. package/dist/prompt/codegen.js +0 -97
  113. package/dist/prompt/render.d.ts +0 -35
  114. package/dist/prompt/render.js +0 -66
  115. package/dist/prompt/run.d.ts +0 -38
  116. package/dist/prompt/run.js +0 -96
  117. package/dist/prompt/types.d.ts +0 -156
  118. /package/dist/{core/schema → cascade}/types.js +0 -0
  119. /package/dist/{prompt → chain}/types.js +0 -0
package/docs/loop.md ADDED
@@ -0,0 +1,318 @@
1
+ # loop
2
+
3
+ Model, tools, model again, until it stops asking.
4
+
5
+ ```ts
6
+ import { loop } from "@spendgraph/harness";
7
+ import { finish, toolbus } from "@spendgraph/tools";
8
+
9
+ const bus = toolbus([lookupClause, issueRefund, finish()]);
10
+
11
+ const result = await loop({
12
+ tools: bus,
13
+ maxTurns: 8,
14
+ maxTokens: 40_000,
15
+ turn: ({ results, last }) =>
16
+ llm.call(messagesFrom(results, last), { tools: bus }),
17
+ });
18
+
19
+ result.stoppedBy; // finished · quiet · turns · tokens · failed
20
+ result.answer; // set when the model called finish()
21
+ result.turns;
22
+ ```
23
+
24
+ ---
25
+
26
+ ## What this adds over a provider's own tool loop
27
+
28
+ `@spendgraph/llms` already loops: it sends the declarations, reads the calls
29
+ back, invokes them and goes round again. What is always hand-rolled around that,
30
+ and always wrong the same way, is the rest:
31
+
32
+ **A ceiling checked before the spend.** `maxTokens` is tested at the top of each
33
+ turn, so it is never crossed rather than merely noticed once it has been.
34
+ `maxTurns` defaults to 8.
35
+
36
+ **Every turn and every tool call recorded.** Including the tool calls that
37
+ failed — a run that dropped them cannot explain why the model changed course.
38
+
39
+ ```
40
+ steps: turn_0 · lookup · lookup · turn_1
41
+ ```
42
+
43
+ **`finished` told apart from `quiet`.**
44
+
45
+ ---
46
+
47
+ ## The distinction worth having
48
+
49
+ Two things end a tool loop and they look identical from outside:
50
+
51
+ | | |
52
+ | --- | --- |
53
+ | `finished` | the model called `finish()` and gave an answer |
54
+ | `quiet` | the model said something and asked for nothing more |
55
+
56
+ The second is what being *done* looks like. It is also exactly what being
57
+ **lost** looks like — the model has run out of ideas and trailed off. Without
58
+ `finish` in the bus you cannot tell them apart, and a run that quietly gave up
59
+ is indistinguishable from one that succeeded.
60
+
61
+ ```ts
62
+ toolbus([...yourTools, finish()]);
63
+ ```
64
+
65
+ `result.answer` is set only on `finished`, and carries what the model said the
66
+ answer was — not the surrounding prose.
67
+
68
+ ---
69
+
70
+ ## A failed tool does not end the run
71
+
72
+ `invoke` returns a failed result rather than throwing, so a tool that fell over
73
+ is something the model can read and work around. It is recorded as a failed step
74
+ and the loop carries on.
75
+
76
+ ```
77
+ turn_0 "let me check"
78
+ nope failed — No tool called "nope". Offered: lookup, finish
79
+ turn_1 "that tool is not available; answering from what I have"
80
+ ```
81
+
82
+ A loop that crashed on a bad call would lose the whole turn, and with it the
83
+ work already done.
84
+
85
+ ---
86
+
87
+ ## Hooks
88
+
89
+ Deterministic code at fixed points. A guardrail written into the prompt is a
90
+ request; this is enforcement — it runs whether or not the model read the
91
+ instruction, and next turn's wording cannot argue it away.
92
+
93
+ ```ts
94
+ await loop({
95
+ tools: bus,
96
+ turn,
97
+ hooks: {
98
+ beforeTurn: () => (overDailyBudget() ? { block: "daily budget spent" } : undefined),
99
+ beforeTool: (call) =>
100
+ call.name === "issue_refund" && Number(call.args.amount) > 50_000
101
+ ? { block: "over the approval limit" }
102
+ : undefined,
103
+ afterTool: (result) => audit.write(result),
104
+ afterTurn: (turn) => log.debug(turn),
105
+ },
106
+ });
107
+ ```
108
+
109
+ ### Gate on what a tool does, not on what it is called
110
+
111
+ `beforeTool` is handed the effect the tool declared, so a rule can be written
112
+ against the thing that matters:
113
+
114
+ ```ts
115
+ hooks: {
116
+ beforeTool: (call, effect) => {
117
+ if (effect === "destructive") return { block: "needs a person" };
118
+ if (effect === undefined) return { block: `${call.name} has not said what it does` };
119
+ },
120
+ }
121
+ ```
122
+
123
+ The version above it — `call.name === "issue_refund"` — is a rule that stops
124
+ firing the day somebody renames the tool. Nothing errors, no test goes red, and
125
+ the refund goes through. A rule written against `effect` survives the rename,
126
+ because it never knew the name.
127
+
128
+ `undefined` means the tool declared nothing, and it is deliberately not
129
+ `readonly`. Blocking it is the safe direction; letting it through means every
130
+ tool written before annotations existed is treated as harmless. Use
131
+ `bus.unannotated()` in a test to keep that list empty.
132
+
133
+ The effect comes from whatever you passed as `tools`: a `toolbus()` answers from
134
+ its registry, a `bus.trace()` from the shortlist it offered that turn. Anything
135
+ else with just an `invoke` is still a valid `tools`, and every effect reads
136
+ `undefined` — which is what it is.
137
+
138
+ ### Blocking a tool is not an error
139
+
140
+ A refused call comes back as a **failed tool result**, in the shape the model
141
+ already knows how to read:
142
+
143
+ ```
144
+ turn_0 "refunding"
145
+ issue_refund failed — blocked: over the approval limit
146
+ turn_1 "understood, escalating"
147
+ ```
148
+
149
+ So the model reads the refusal and works around it. Throwing instead would lose
150
+ the turn and the work already done in it. The tool itself never runs — a
151
+ guardrail that refunds first and refuses afterwards is not a guardrail.
152
+
153
+ ### Blocking a turn ends the run
154
+
155
+ There is no way around a blocked turn, so it stops with `stoppedBy: "blocked"`
156
+ and a `failed` status. That is the shape for a budget ceiling or a kill switch.
157
+
158
+ ### A hook that throws is treated as permission
159
+
160
+ A bug in the guardrail must not take down the thing it was guarding. The call it
161
+ was checking has not happened yet, the next hook still gets its say, and the run
162
+ continues. The alternative — an unreachable policy service ending every run —
163
+ fails far more often than the case it was protecting against.
164
+
165
+ The observing hooks, `afterTool` and `afterTurn`, change nothing when they
166
+ throw. They were only watching.
167
+
168
+ ### Why hooks rather than an `if` inside the tool
169
+
170
+ A blocked call is a **step**, so "what did this agent try to do and get refused"
171
+ is a query rather than a grep. `afterTool` sees blocked results as well as
172
+ successful ones, which is what an audit trail actually wants.
173
+
174
+ ## Telling the model what is left
175
+
176
+ The ceilings stop a run. They do not stop the model planning as if there were no
177
+ ceilings, which is how turn seven of eight opens a five-step approach and gets
178
+ cut off after the first — paid for in full.
179
+
180
+ `remaining` is on the state your `turn` already receives:
181
+
182
+ ```ts
183
+ turn: ({ remaining }) =>
184
+ model.call([
185
+ { role: "user", content: question },
186
+ { role: "user", content: `${remaining.turns} turns and about ${remaining.tokens} tokens left.` },
187
+ ], { tools: bus.trace(question) });
188
+ ```
189
+
190
+ | | |
191
+ | --- | --- |
192
+ | `remaining.turns` | turns left, **this one included** — the last turn reads `1`, not `0` |
193
+ | `remaining.tokens` | what is left of `maxTokens`, floored at zero |
194
+
195
+ `beforeTurn` gets the same state, so a run can be stopped rather than started
196
+ with a budget that cannot finish it:
197
+
198
+ ```ts
199
+ beforeTurn: ({ remaining }) =>
200
+ remaining.tokens < 30_000 ? { block: "not enough left to finish" } : undefined,
201
+ ```
202
+
203
+ ### Putting it in the prompt is your job
204
+
205
+ The loop hands you the number and stops there. Which wording works is
206
+ task-specific, and a library that quietly appends a sentence to your messages
207
+ invalidates your cached prefix without asking.
208
+
209
+ ### No ceiling reads as `Infinity`, never zero
210
+
211
+ This is the whole reason the field needs a test. The obvious check —
212
+
213
+ ```ts
214
+ if (remaining.tokens < 1000) return "wrap up now";
215
+ ```
216
+
217
+ — would fire on the **first turn of every run that never set `maxTokens`** if a
218
+ missing budget read as zero. The agent would refuse to start work because it
219
+ believes it is already out, and it would look like a model problem rather than a
220
+ library one.
221
+
222
+ ---
223
+
224
+ ## Compaction
225
+
226
+ A long run gets expensive because the transcript grows, not because the work got
227
+ harder. Every turn resends what came before, so the tool results are the bulk of
228
+ what you pay for on turn eight.
229
+
230
+ ```ts
231
+ await loop({
232
+ tools: bus,
233
+ maxTokens: 40_000,
234
+ compact: {
235
+ at: 0.7, // fraction of the ceiling
236
+ keep: 4, // recent results, verbatim
237
+ summarise: (older) => lead.call(summarisePrompt(older)),
238
+ },
239
+ turn: ({ results, last }) => llm.call(messagesFrom(results, last), { tools: bus }),
240
+ });
241
+
242
+ result.compactions; // { count: 2, replaced: 11 }
243
+ ```
244
+
245
+ The older results are replaced by **one synthetic result named `compacted`**,
246
+ which the next turn reads like any other. The most recent `keep` survive
247
+ verbatim: summarising what just happened is how a loop forgets what it was in
248
+ the middle of doing.
249
+
250
+ ### It measures the context, not the spend
251
+
252
+ The threshold is on **the last turn's `inputTokens`** — the size of what is
253
+ actually being resent — and not on what the run has spent so far.
254
+
255
+ Spend only ever rises. A threshold on it fires once and then on every turn after,
256
+ compacting an already-compacted history again and again. The input count falls
257
+ the moment a compaction lands, so the next turn is under the line and it does not
258
+ fire again until the history has genuinely grown back.
259
+
260
+ Set `after: 12` instead to compact on a result count, which needs no ceiling.
261
+
262
+ ### What it costs
263
+
264
+ The summary is a model call, recorded as a `compact` step with its tokens and
265
+ counted against `maxTokens`. A compaction that costs more than it saves still has
266
+ to be paid for, and the step is there so you can see whether it did.
267
+
268
+ **A failed summariser is survivable.** The run carries on with the history it
269
+ has rather than ending over a summary it could not write — losing the compaction
270
+ costs tokens, losing the run costs the work.
271
+
272
+ **The honest caveat:** compaction loses information, and the loss is silent. A
273
+ run that compacted and then got the answer wrong looks identical to one that was
274
+ simply wrong.
275
+
276
+ ## Reading `stoppedBy` in production
277
+
278
+ | | What it means |
279
+ | --- | --- |
280
+ | `finished` | working as intended |
281
+ | `quiet` | offer it `finish()`, or the prompt is not telling it what done looks like |
282
+ | `turns` | the task needs more steps than you allowed, or it is going in circles |
283
+ | `tokens` | the ceiling is too low, or the history is growing faster than the work |
284
+ | `failed` | the provider failed; not an agent problem |
285
+
286
+ A rising share of `turns` is the signal to look at the tool descriptions: a model
287
+ that cannot tell two tools apart calls both and gets nowhere.
288
+
289
+ ---
290
+
291
+ ## When not to use it
292
+
293
+ **The steps are known in advance.** That is `chain` — a loop pays for the model
294
+ to decide what to do next, and if you already know, you are paying for nothing.
295
+
296
+ **There are no tools.** A loop with an empty bus is one call with extra
297
+ machinery.
298
+
299
+ **The work is unbounded and the budget is not.** This is the workflow that can
300
+ spend the most, which is why both ceilings are here and why `maxTurns` has a
301
+ default when the others do not.
302
+
303
+ ---
304
+
305
+ ## How the files fit
306
+
307
+ ```
308
+ types.ts the contract
309
+
310
+ turn.ts one model call: what it cost, how it is recorded
311
+ act.ts running what it asked for, and reading `finish`
312
+
313
+ loop.ts the cycle and its ceilings
314
+ ```
315
+
316
+ Not compiled to a graph, unlike `route` and `chain`. This is a cycle, and
317
+ `@spendgraph/graph` is a DAG whose step ceiling exists to stop one — same
318
+ reasoning as `refine`.
@@ -0,0 +1,128 @@
1
+ # orchestrate
2
+
3
+ A lead decomposes, workers do the pieces, the lead puts it back together.
4
+
5
+ ```ts
6
+ import { orchestrate } from "@spendgraph/harness";
7
+
8
+ const result = await orchestrate(
9
+ { brief },
10
+ {
11
+ plan: async (v) => {
12
+ const reply = await lead.call(planPrompt(v), { schema: SUBTASKS });
13
+ return { ...reply.data, model: reply.model, inputTokens: reply.inputTokens, outputTokens: reply.outputTokens };
14
+ },
15
+ work: (subtask, v) => worker.call(sectionPrompt(subtask, v)),
16
+ synthesise: (results, v) => lead.call(assemblePrompt(results, v)),
17
+ maxWorkers: 5,
18
+ concurrency: 4,
19
+ maxTokens: 80_000,
20
+ }
21
+ );
22
+
23
+ result.subtasks; // what the lead asked for, after the cap
24
+ result.dropped; // what the cap refused
25
+ result.stoppedBy; // completed · planned-nothing · workers-failed · tokens · failed
26
+ ```
27
+
28
+ The workflow for work whose **shape is not known until the input is read** —
29
+ which is the whole difference from `chain`, where the stages are named in
30
+ advance.
31
+
32
+ ---
33
+
34
+ ## The cap is the point
35
+
36
+ This is the workflow that can spend the most, because the **lead** decides how
37
+ many calls to make. Asked to decompose a large input it will cheerfully invent
38
+ twelve subtasks, and twelve model calls leave before anyone has decided that was
39
+ reasonable.
40
+
41
+ `maxWorkers` defaults to 5. Anything past it is **dropped and said**, never
42
+ dropped quietly:
43
+
44
+ ```
45
+ result.subtasks ["a", "b", "c"]
46
+ result.dropped ["d", "e", "f", "g"]
47
+ steps[0].error "4 subtask(s) past the worker cap were dropped: d, e, f, g"
48
+ ```
49
+
50
+ It keeps the head and drops the tail: a lead asked for the most important piece
51
+ first usually gives it first, and truncating from the front would throw away
52
+ exactly what it thought mattered.
53
+
54
+ A duplicated name is made unique — `summary`, `summary_1` — rather than
55
+ rejected. A lead naming two sections the same thing is a wording problem, not a
56
+ reason to lose the run.
57
+
58
+ ---
59
+
60
+ ## When the lead finds nothing to do
61
+
62
+ ```ts
63
+ { stoppedBy: "planned-nothing", output: "There is nothing here to summarise." }
64
+ ```
65
+
66
+ The workers never run and `synthesise` is never called. Synthesising over an
67
+ empty list asks a model to write an answer from nothing, and it will — which is
68
+ worse than saying there was nothing.
69
+
70
+ ---
71
+
72
+ ## When a worker falls over
73
+
74
+ `minSuccess` decides, and it defaults to **all**.
75
+
76
+ ```ts
77
+ minSuccess: 2 // three sections planned, two is enough
78
+ ```
79
+
80
+ Sectioning a document usually wants all of them: a hole in the middle is worse
81
+ than no answer. Research-style fan-out often does not — two sources out of three
82
+ still supports a conclusion. Without a `minSuccess` the run fails and
83
+ `synthesise` is not called, so the lead is never asked to write around a gap it
84
+ does not know about.
85
+
86
+ ---
87
+
88
+ ## The ceiling is checked twice
89
+
90
+ Once after planning and once before synthesising, because both are places where
91
+ the run can be abandoned before spending more:
92
+
93
+ - the plan alone reaching the ceiling means the workers never start
94
+ - the workers reaching it means the lead is never asked to assemble
95
+
96
+ Tokens rather than money, because this package cannot price a call — that
97
+ happens on the server, after the fact.
98
+
99
+ ---
100
+
101
+ ## When not to use it
102
+
103
+ **The stages are known in advance.** That is `chain`. Paying a lead to decide
104
+ what you already know is a call for nothing.
105
+
106
+ **The pieces are independent and uniform.** That is `parallel` — you do not need
107
+ a model to tell you a document has three sections if you already know it does.
108
+
109
+ **One call would do.** Three calls minimum here: plan, work, synthesise. On a
110
+ small input the lead's overhead outweighs the parallelism.
111
+
112
+ ---
113
+
114
+ ## How the files fit
115
+
116
+ ```
117
+ types.ts the contract
118
+
119
+ plan.ts the lead's decomposition, and the cap
120
+ work.ts the workers, delegated to `parallel`
121
+
122
+ orchestrate.ts plan · work · synthesise
123
+ ```
124
+
125
+ `work.ts` does not fan out again — `parallel` already caps the concurrency, turns
126
+ a thrown worker into a result so the others land, and numbers the steps by
127
+ declaration so two runs can be compared. This is the workflow that most benefits
128
+ from the others existing first.
@@ -0,0 +1,139 @@
1
+ # parallel
2
+
3
+ Every task at once, then one answer out of what came back. Two shapes use it:
4
+ **sectioning** buys wall-clock, **voting** buys confidence.
5
+
6
+ ---
7
+
8
+ ## Sectioning — split the work
9
+
10
+ ```ts
11
+ import { parallel } from "@spendgraph/harness";
12
+
13
+ const result = await parallel(
14
+ { brief },
15
+ {
16
+ tasks: [
17
+ { name: "intro", run: (v) => llm.call(introPrompt(v)) },
18
+ { name: "body", run: (v) => llm.call(bodyPrompt(v)) },
19
+ { name: "close", run: (v) => llm.call(closePrompt(v)) },
20
+ ],
21
+ merge: (results) => results.map((r) => r.value.output).join("\n\n"),
22
+ concurrency: 4,
23
+ }
24
+ );
25
+ ```
26
+
27
+ Three calls take as long as the slowest, not the sum. The trade against `chain`
28
+ is exact: chain buys accuracy with latency, parallel buys latency with tokens —
29
+ you pay for the same context three times over.
30
+
31
+ **A missing section fails the run by default.** A hole in the middle of an
32
+ answer is worse than no answer, and `merge` is never called with one. The tasks
33
+ that did land are still recorded, so the failure is legible.
34
+
35
+ ---
36
+
37
+ ## Voting — ask N times, take the consensus
38
+
39
+ ```ts
40
+ import { majority, parallel, repeat } from "@spendgraph/harness";
41
+
42
+ const result = await parallel(
43
+ { question },
44
+ {
45
+ tasks: repeat("guess", 5, (v) => llm.call(prompt(v))),
46
+ merge: (results) => majority(results)?.value,
47
+ minSuccess: 3,
48
+ }
49
+ );
50
+
51
+ result.merged; // the answer most of them gave
52
+ ```
53
+
54
+ `repeat` **rounds an even count up.** Four voters tie, and a tie has no answer
55
+ that is not arbitrary.
56
+
57
+ `minSuccess: 3` is the point of running five: if one call is overloaded the other
58
+ four still land and still answer. Ties go to the first declared, so the same
59
+ inputs give the same result on every run.
60
+
61
+ **When voting is worth the tokens:** the model is right most of the time and
62
+ wrong *differently* each time — the wrong answers scatter, the right one repeats.
63
+
64
+ **When it buys nothing:** the model is confidently wrong the same way every
65
+ time. Five identical wrong answers vote unanimously for the wrong one, and you
66
+ have paid five times to be misled with more conviction. That is the failure it is
67
+ easiest to mistake this pattern for a fix for.
68
+
69
+ ---
70
+
71
+ ## Why it is not a graph
72
+
73
+ `route` and `chain` compile down to `@spendgraph/graph`. This one cannot:
74
+ `graph` walks one node at a time and picks a single edge, so a fan-out is not a
75
+ shape it can express. The concurrency comes from `mapLimit`, which already exists
76
+ in `@spendgraph/prompt/internals`.
77
+
78
+ Same reasoning as `refine`, which is a loop rather than a DAG walk.
79
+
80
+ ---
81
+
82
+ ## Steps are numbered by declaration, not by arrival
83
+
84
+ A fan-out that numbered steps by completion order would record a different run
85
+ every time for identical work, and two runs could not be compared. The slow task
86
+ declared first is still step 0.
87
+
88
+ ```
89
+ steps: slow (index 0) ← declared first, landed last
90
+ quick (index 1)
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Failure is a result, not an exception
96
+
97
+ A task that throws comes back as a failed `TaskResult`. The whole point of
98
+ running five is that the other four still land, so one bad call cannot take the
99
+ run down with it.
100
+
101
+ ```ts
102
+ result.succeeded; // ["a", "b"]
103
+ result.failed; // ["c"]
104
+ result.results; // every one, in declaration order
105
+ ```
106
+
107
+ `minSuccess` decides whether that is fatal. Sectioning defaults to all —
108
+ anything less is a hole. Voting should set a majority.
109
+
110
+ ---
111
+
112
+ ## When not to use it
113
+
114
+ **The tasks need each other's output.** That is `chain`; a fan-out cannot pass
115
+ anything between branches.
116
+
117
+ **One call would do.** Voting on a task the model gets right every time is five
118
+ times the cost for the same answer.
119
+
120
+ **The context is enormous.** Sectioning resends it per task. Three sections of a
121
+ 50k-token brief is 150k tokens of input, and the latency you bought may cost more
122
+ than it saved.
123
+
124
+ ---
125
+
126
+ ## How the files fit
127
+
128
+ ```
129
+ types.ts the contract
130
+
131
+ task.ts run one, record one
132
+ merge.ts repeat · majority
133
+
134
+ parallel.ts fan out · gather · merge
135
+ ```
136
+
137
+ `task.ts` and `merge.ts` are siblings. `majority` and `repeat` are pure and
138
+ tested on their own — the tie-break and the even-count rounding are the parts
139
+ worth pinning.