@spendgraph/harness 0.1.0 → 0.2.1

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 +141 -111
  2. package/dist/cascade/cascade.d.ts +13 -0
  3. package/dist/cascade/cascade.js +80 -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 +32 -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 +50 -0
  11. package/dist/chain/gate.d.ts +16 -0
  12. package/dist/chain/gate.js +25 -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 +7 -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 +29 -0
  22. package/dist/loop/compact.d.ts +49 -0
  23. package/dist/loop/compact.js +41 -0
  24. package/dist/loop/hooks.d.ts +43 -0
  25. package/dist/loop/hooks.js +31 -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 +99 -0
  30. package/dist/loop/turn.d.ts +6 -0
  31. package/dist/loop/turn.js +15 -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 +56 -0
  38. package/dist/orchestrate/plan.d.ts +18 -0
  39. package/dist/orchestrate/plan.js +27 -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 +25 -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 +35 -0
  48. package/dist/parallel/parallel.d.ts +12 -0
  49. package/dist/parallel/parallel.js +56 -0
  50. package/dist/parallel/task.d.ts +13 -0
  51. package/dist/parallel/task.js +37 -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 +24 -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 +14 -0
  60. package/dist/refine/refine.d.ts +10 -0
  61. package/dist/refine/refine.js +59 -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 +33 -0
  66. package/dist/route/dispatch.d.ts +12 -0
  67. package/dist/route/dispatch.js +25 -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 +22 -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 +10 -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 +48 -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 +19 -15
  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
@@ -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.
package/docs/refine.md ADDED
@@ -0,0 +1,206 @@
1
+ # refine
2
+
3
+ Draft, judge, revise. Stop when it is good enough, or when the budget is out.
4
+
5
+ ```ts
6
+ import { refine } from "@spendgraph/harness";
7
+
8
+ const result = await refine({
9
+ rounds: 3,
10
+ maxTokens: 20_000,
11
+ attempt: (feedback) =>
12
+ llm.call([{ role: "user", content: feedback ? `${task}\n\nFix this: ${feedback}` : task }]),
13
+ judge: (attempt) =>
14
+ attempt.output.length <= 160
15
+ ? { accepted: true }
16
+ : { accepted: false, feedback: `That is ${attempt.output.length} characters. The limit is 160.` },
17
+ });
18
+
19
+ result.output; // the winning draft
20
+ result.accepted; // false when the rounds or the tokens ran out
21
+ result.stoppedBy; // accepted · rounds · tokens · failed
22
+ result.steps; // every attempt and every judgement
23
+ ```
24
+
25
+ One `attempt` callback rather than separate generate and revise, because in
26
+ practice they are the same prompt with one extra paragraph — `feedback` is null
27
+ on the first round and the judge's note after.
28
+
29
+ ---
30
+
31
+ ## Example: an invoice that has to add up
32
+
33
+ The judge is plain code, which is the case worth reaching for. It costs nothing,
34
+ it is deterministic, and it cannot be talked into agreeing.
35
+
36
+ ```ts
37
+ const result = await refine({
38
+ rounds: 3,
39
+
40
+ attempt: (feedback) =>
41
+ llm.call([{ role: "user", content: `${email}${feedback ?? ""}` }], { schema: INVOICE }),
42
+
43
+ judge: (attempt) => {
44
+ const invoice = JSON.parse(attempt.output);
45
+ const summed = invoice.lines.reduce((n, l) => n + l.amount, 0);
46
+
47
+ return summed === invoice.total
48
+ ? { accepted: true }
49
+ : {
50
+ accepted: false,
51
+ feedback: `\n\nThe lines add up to ${summed} but the total says ${invoice.total}. A line is missing.`,
52
+ };
53
+ },
54
+ });
55
+ ```
56
+
57
+ What happens:
58
+
59
+ ```
60
+ round 0 → { lines: [1200, 50], total: 1550 } ✗ lines sum to 1250
61
+ feedback: "The lines add up to 1250 but the total says 1550…"
62
+ round 1 → { lines: [1200, 50, 300], total: 1550 } ✓ accepted
63
+
64
+ accepted: true rounds: 2 steps: 4
65
+ ```
66
+
67
+ The model dropped the shipping line. Told exactly what was inconsistent, it
68
+ found it. A **retry** would have run the same prompt and often made the same
69
+ mistake; the feedback is the difference.
70
+
71
+ A model judge asked "is this extracted correctly?" would have said yes, because
72
+ the JSON looks fine. Arithmetic does not have that problem.
73
+
74
+ ---
75
+
76
+ ## More judges that cost nothing
77
+
78
+ **SQL that has to run.** `EXPLAIN` is free and the database's own error is the
79
+ best feedback there is.
80
+
81
+ ```ts
82
+ judge: async (attempt) => {
83
+ try {
84
+ await db.prepare(`EXPLAIN ${attempt.output}`).run();
85
+ return { accepted: true };
86
+ } catch (err) {
87
+ return { accepted: false, feedback: String(err.message) };
88
+ }
89
+ }
90
+ ```
91
+
92
+ Hand the error back verbatim. Rewriting it into something friendlier throws away
93
+ the column name that tells the model what to fix.
94
+
95
+ **Code that has to pass its tests.** The strongest judge there is, because it is
96
+ the same bar a person would apply. Slow, so keep `rounds` low — three failed
97
+ attempts at a minute each is three minutes before you learn the prompt is wrong.
98
+
99
+ ```ts
100
+ judge: async (attempt) => {
101
+ const { failed, output } = await runTests(attempt.output);
102
+ return failed === 0 ? { accepted: true } : { accepted: false, feedback: output };
103
+ }
104
+ ```
105
+
106
+ **Placeholders that must survive a translation.** Models translate `{count}`
107
+ about one time in fifty, and it fails at runtime for whoever speaks that
108
+ language.
109
+
110
+ ```ts
111
+ judge: (attempt) => {
112
+ const wanted = source.match(/\{[a-z_]+\}/g) ?? [];
113
+ const missing = wanted.filter((token) => !attempt.output.includes(token));
114
+ return missing.length === 0
115
+ ? { accepted: true }
116
+ : { accepted: false, feedback: `Keep these exactly as they are: ${missing.join(", ")}.` };
117
+ }
118
+ ```
119
+
120
+ ---
121
+
122
+ ## When the judge has to be a model
123
+
124
+ Only when no code can decide — tone, policy, "would a person find this rude".
125
+ It is a second model call every round, so the loop is now at least twice the
126
+ price of the answer.
127
+
128
+ ```ts
129
+ judge: async (attempt) => {
130
+ const verdict = await judgeLlm.call([{ role: "user", content: `${RUBRIC}\n\n${attempt.output}` }], {
131
+ schema: {
132
+ type: "object",
133
+ properties: { score: { type: "number" }, fix: { type: "string" } },
134
+ required: ["score"],
135
+ },
136
+ });
137
+
138
+ const { score, fix } = verdict.data;
139
+ return score >= 8 ? { accepted: true, score } : { accepted: false, score, feedback: fix };
140
+ }
141
+ ```
142
+
143
+ Always return `score`, even on acceptance. Without it, a run that never passes
144
+ hands back the **last** attempt rather than the best one.
145
+
146
+ ---
147
+
148
+ ## When not to use it
149
+
150
+ **You cannot state the bar.** If the judge accepts everything you have tripled
151
+ the cost to learn nothing. Write the check first; if you cannot, the pattern does
152
+ not apply.
153
+
154
+ **Round one already passes almost always.** At 99% you are paying a judge on
155
+ every request to catch the hundredth. Retry-on-failure is cheaper than a loop by
156
+ default.
157
+
158
+ **The failure is not fixable by trying again.** A missing document, a permission
159
+ the agent does not have, a question with no answer — feedback cannot help, and
160
+ three rounds is three ways of saying so. Escalate instead.
161
+
162
+ ---
163
+
164
+ ## Reading the result
165
+
166
+ `stoppedBy` is the field to watch in production.
167
+
168
+ | | What it means |
169
+ | --- | --- |
170
+ | `accepted` | working as intended |
171
+ | `rounds` | the judge is too strict, or the prompt cannot get there |
172
+ | `tokens` | the ceiling is too low, or the attempts are too big |
173
+ | `failed` | the provider failed; not a quality problem |
174
+
175
+ A rising share of `rounds` is the signal to fix the prompt rather than raise the
176
+ limit. `history` carries every attempt with its verdict and token count, so "does
177
+ refining pay on this task?" is a query — round-one acceptance rate against the
178
+ cost of the extra calls — rather than a hunch.
179
+
180
+ **A failed attempt ends the run** rather than being judged. Asking a judge what
181
+ it thinks of an error spends a call to be told it is bad.
182
+
183
+ **The ceiling is in tokens, not money**, because this package cannot price a
184
+ call — that happens on the server, after the fact. It is checked *before* each
185
+ round, so it is never crossed rather than merely noticed once it has been.
186
+
187
+ ---
188
+
189
+ ## How the files fit
190
+
191
+ ```
192
+ types.ts the contract
193
+
194
+ attempt.ts what a draft cost, and how it is recorded
195
+ judge.ts how a verdict is recorded, and which draft wins
196
+
197
+ refine.ts the loop: draft · judge · stop
198
+ ```
199
+
200
+ `attempt.ts` and `judge.ts` are siblings — neither knows about the other, and
201
+ both import only the contract. The loop is the only file that knows the order
202
+ they run in.
203
+
204
+ `best` lives with the judge rather than the loop because the choice is the
205
+ judge's: it is made on the verdicts, and a judge that never scores gets the
206
+ fallback to the last attempt.
package/docs/route.md ADDED
@@ -0,0 +1,212 @@
1
+ # route
2
+
3
+ Classify once, then hand the work to the branch built for it.
4
+
5
+ ```ts
6
+ import { route } from "@spendgraph/harness";
7
+
8
+ const result = await route(
9
+ { subject, body },
10
+ {
11
+ classify: ({ subject }) => (/refund|invoice/i.test(subject) ? "billing" : "technical"),
12
+ routes: {
13
+ billing: (input) => haiku.bind(billingPrompt).invoke(input),
14
+ technical: (input) => opus.bind(docsPrompt).invoke(input),
15
+ },
16
+ fallback: (input) => opus.bind(generalPrompt).invoke(input),
17
+ }
18
+ );
19
+
20
+ result.route; // "billing", or null when the fallback ran
21
+ result.classified; // what the classifier said, used or not
22
+ result.inputTokens; // the classifier's tokens plus the branch's
23
+ ```
24
+
25
+ ---
26
+
27
+ ## Example: support triage, routed for cost
28
+
29
+ Most tickets are simple. Only a few need the expensive model.
30
+
31
+ ```ts
32
+ const result = await route(
33
+ { subject, body },
34
+ {
35
+ classify: ({ subject, body }) => {
36
+ const text = `${subject} ${body}`.toLowerCase();
37
+ if (/refund|charged|invoice/.test(text)) return "billing";
38
+ if (/error|crash|not working/.test(text)) return "technical";
39
+ return "general";
40
+ },
41
+
42
+ routes: {
43
+ billing: (input) => haiku.bind(billingPrompt).invoke(input),
44
+ technical: (input) => opus.bind(docsPrompt).invoke(input),
45
+ general: () => ({ output: "Thanks — a human will reply within a day.", model: "none" }),
46
+ },
47
+
48
+ fallback: (input) => opus.bind(generalPrompt).invoke(input),
49
+ }
50
+ );
51
+ ```
52
+
53
+ ```
54
+ result.route "billing"
55
+ result.output "Your 12 March charge was a duplicate; it's been refunded."
56
+ result.inputTokens 340 classifier (0) + branch
57
+ result.steps [ classify, billing ]
58
+ ```
59
+
60
+ Three things this is doing:
61
+
62
+ The **classifier is a regex** — free, instant, and it cannot invent a category.
63
+ That is the common case.
64
+
65
+ The **`general` branch calls no model at all.** It returns a canned line at zero
66
+ cost. If a third of your tickets land there, that third is now free, which no
67
+ amount of prompt tuning gets you.
68
+
69
+ **`billing` uses the small model, `technical` the big one.** That is the whole
70
+ cost argument: you stop paying opus rates for "where is my invoice".
71
+
72
+ ---
73
+
74
+ ## Example: a classifier that is a model
75
+
76
+ When no regex will ever be right — sentiment, intent, "is this actually a
77
+ complaint" — use a model, and constrain it with an enum so it can only answer
78
+ with a route you have.
79
+
80
+ ```ts
81
+ classify: async ({ subject, body }) => {
82
+ const reply = await haiku.call(
83
+ [{ role: "user", content: `Classify this ticket.\n\n${subject}\n${body}` }],
84
+ {
85
+ schema: {
86
+ type: "object",
87
+ properties: {
88
+ route: { type: "string", enum: ["billing", "technical", "general"] },
89
+ confidence: { type: "number" },
90
+ reason: { type: "string" },
91
+ },
92
+ required: ["route", "confidence"],
93
+ },
94
+ }
95
+ );
96
+
97
+ return {
98
+ ...reply.data,
99
+ model: reply.model,
100
+ inputTokens: reply.inputTokens,
101
+ outputTokens: reply.outputTokens,
102
+ };
103
+ },
104
+ minConfidence: 0.7,
105
+ ```
106
+
107
+ The `enum` is doing real work. A free-text classifier eventually answers
108
+ `"Billing"` or `"billing question"` or a whole sentence, and every one of those
109
+ falls through to the fallback silently. Constrained, it can only say one of
110
+ three things — and on Anthropic that is a forced tool call, which is a guarantee
111
+ rather than a request.
112
+
113
+ Returning `model` and the token counts is what keeps the cost honest: a
114
+ classifier running on every request is not free, and without them
115
+ `result.inputTokens` reports only the branch.
116
+
117
+ **Self-reported `confidence` is weak.** Models are overconfident and the number
118
+ is not calibrated. `minConfidence` is a blunt safety net for the genuinely
119
+ ambiguous cases, not a dial to tune to three decimal places.
120
+
121
+ **Check the arithmetic before reaching for it.** A haiku classify might be 300
122
+ in, 20 out. Routing 80% of traffic from opus to haiku pays for itself many times
123
+ over. If every branch is a cheap model anyway, you have added a call to every
124
+ request to save nothing.
125
+
126
+ ---
127
+
128
+ ## What else it is for
129
+
130
+ **Capability.** A refund branch gets a bus with `escalate` pinned and a payments
131
+ tool; a docs branch gets retrieval and no write access. You cannot express that
132
+ in one call, because the tools are chosen before you know what was asked.
133
+
134
+ **Quality.** One prompt covering billing, technical and legal has to carry
135
+ instructions for all three, and every instruction is a chance to apply the wrong
136
+ one. Three prompts each do one thing.
137
+
138
+ **Safety.** Classify first; if it is out of scope or harmful, route to a branch
139
+ that declines and hands off. More reliable than hoping one prompt's guardrails
140
+ hold, because the decision is a separate call you can test.
141
+
142
+ Beyond support, the same shape: document type → invoice, contract or receipt,
143
+ each with its own extraction schema. Language detection → a localised prompt.
144
+ Complexity → a template for the trivial, an agent loop for the rest.
145
+
146
+ ---
147
+
148
+ ## When not to use it
149
+
150
+ **The branches would share 90% of their prompt.** You have split one thing into
151
+ three that now drift apart.
152
+
153
+ **The classification is genuinely hard.** You have moved the failure earlier
154
+ without removing it — a confidently wrong route sends the question to a
155
+ specialist with no idea what to do with it. `minConfidence` and a required
156
+ `fallback` exist for exactly this, but they are a net, not a fix.
157
+
158
+ ---
159
+
160
+ ## Reading the result
161
+
162
+ **Watch `route === null`.** A rising share of fallbacks means either the
163
+ classifier is drifting or there is a kind of request you never built a branch
164
+ for — and since `classified` records what the classifier *said* separately from
165
+ what ran, you can tell which.
166
+
167
+ ```
168
+ classifier says "legal", no such branch → classified "legal" route null
169
+ classifier says "billing" at 0.4, bar 0.7 → classified "billing" route null
170
+ ```
171
+
172
+ Same `route`, different `classified`: the first is a **missing branch**, the
173
+ second a **miscalibrated** one, and they want opposite fixes.
174
+
175
+ **`fallback` is required.** A router that can fail to route fails in production
176
+ at 3am, and "unknown" is a class every classifier eventually returns.
177
+
178
+ `route` and `refine` compose: a branch can be a refine loop, and its rounds
179
+ appear as `billing.attempt_0`, `billing.judge_0` rather than being flattened
180
+ into one step.
181
+
182
+ ---
183
+
184
+ ## How the files fit
185
+
186
+ ```
187
+ types.ts the contract
188
+
189
+ classify.ts what the classifier said, and what runs because of it
190
+
191
+ dispatch.ts where the work goes: a node per branch, and the edges to them
192
+
193
+ route.ts compile · run · shape the result
194
+ ```
195
+
196
+ Each file imports only downwards, which is why `decide` can be tested on its own
197
+ with no graph in sight.
198
+
199
+ It compiles to a graph rather than looping here. `@spendgraph/graph` already
200
+ validates that every branch is reachable and that no default sits in front of
201
+ one, already records a step per node with its tokens, and already returns the
202
+ rollout shape. A second execution engine in this package would be a second thing
203
+ to keep right.
204
+
205
+ **The decision travels in the graph, not in a closure.** The classifier
206
+ *returns* its decision, so it lands in `outputs.classify` like any node's return
207
+ and the edges read it from the context they are already handed. A finished run
208
+ can be read apart from `outputs.classify` alone.
209
+
210
+ **The edge order matters.** Conditional edges first, the unconditional fallback
211
+ last — `graph` refuses the other order, and is right to: an unconditional edge
212
+ ahead of a conditional one means the branch behind it can never be reached.