@trazum/core 1.50.5 → 1.50.7
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.
- package/dist/config-schema.d.ts +19 -1
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +53 -0
- package/dist/config-schema.js.map +1 -1
- package/dist/experiment.d.ts +134 -0
- package/dist/experiment.d.ts.map +1 -0
- package/dist/experiment.js +168 -0
- package/dist/experiment.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ladder.d.ts +169 -0
- package/dist/ladder.d.ts.map +1 -0
- package/dist/ladder.js +191 -0
- package/dist/ladder.js.map +1 -0
- package/package.json +1 -1
- package/src/config-schema.ts +82 -0
- package/src/experiment.ts +275 -0
- package/src/index.ts +25 -0
- package/src/ladder.ts +295 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two arms on real traffic, and the three things that stop it being theatre.
|
|
3
|
+
*
|
|
4
|
+
* `eval` compares two prompts on cases somebody wrote; `route` compares two
|
|
5
|
+
* models on the same. Both measure agreement in a laboratory. The traffic is
|
|
6
|
+
* the only place the real question gets answered — and the moment a comparison
|
|
7
|
+
* runs on real traffic, three failures become available that a laboratory does
|
|
8
|
+
* not have.
|
|
9
|
+
*
|
|
10
|
+
* ## 1. A winner where there is none
|
|
11
|
+
*
|
|
12
|
+
* Two arms always produce two numbers, and one of them is always larger. An
|
|
13
|
+
* A/B report that names a winner from that is a coin flip with a dashboard.
|
|
14
|
+
* The verdict here is **three-valued**, the way `verify`'s has been since
|
|
15
|
+
* 1.39: A wins, B wins, or **not separable on this traffic** — and the third
|
|
16
|
+
* one comes with the number of outcomes per arm that *would* separate them, so
|
|
17
|
+
* "run it longer" is a quantified instruction rather than a shrug.
|
|
18
|
+
*
|
|
19
|
+
* ## 2. Peeking
|
|
20
|
+
*
|
|
21
|
+
* A test stopped on the first afternoon it looked good is not a test. The
|
|
22
|
+
* stopping rule is declared **before** the experiment starts, and the report
|
|
23
|
+
* says whether it was honoured. It cannot enforce that — nobody can stop
|
|
24
|
+
* somebody reading a number early — but it can make an early stop *visible*
|
|
25
|
+
* to whoever reads the result later, which is the part that matters.
|
|
26
|
+
*
|
|
27
|
+
* ## 3. Quality and cost judged apart, then together
|
|
28
|
+
*
|
|
29
|
+
* The interesting arm is almost never better *and* cheaper. It is better and
|
|
30
|
+
* dearer, and the decision needs one figure nobody computes: **what an extra
|
|
31
|
+
* success costs**. That is the marginal figure — the difference in spend over
|
|
32
|
+
* the difference in successes — and it is the number a product decision
|
|
33
|
+
* actually turns on, printed rather than left as an exercise.
|
|
34
|
+
*
|
|
35
|
+
* ## The statistics are shown, not asserted
|
|
36
|
+
*
|
|
37
|
+
* Wilson score intervals per arm and Newcombe's interval on the difference,
|
|
38
|
+
* because both behave at the small samples this will actually see. The
|
|
39
|
+
* intervals are returned, not just the verdict: a reader who disagrees with
|
|
40
|
+
* the threshold can see the numbers it was applied to, which is the same
|
|
41
|
+
* discipline `eval` established by running the original twice before judging
|
|
42
|
+
* anything against it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { judgeOutcome } from './outcome.js';
|
|
46
|
+
import type { OutcomeTally, OutcomeVocabulary } from './outcome.js';
|
|
47
|
+
|
|
48
|
+
/** z for a two-sided 95% interval, and for 80% power. */
|
|
49
|
+
const Z_95 = 1.959963984540054;
|
|
50
|
+
const Z_POWER_80 = 0.8416212335729143;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The declaration, made before the experiment runs.
|
|
54
|
+
*
|
|
55
|
+
* Its whole purpose is to exist *earlier* than the result. A stopping rule
|
|
56
|
+
* invented after looking at the numbers is not a stopping rule.
|
|
57
|
+
*/
|
|
58
|
+
export interface ExperimentDeclaration {
|
|
59
|
+
/** Two arms. More would need a multiple-comparison correction nobody asked for. */
|
|
60
|
+
arms: [string, string];
|
|
61
|
+
/**
|
|
62
|
+
* Outcomes each arm must record before the result may be read.
|
|
63
|
+
*
|
|
64
|
+
* Declared as a count rather than a duration, because a duration is a proxy
|
|
65
|
+
* for a count and the proxy breaks the week traffic doubles.
|
|
66
|
+
*/
|
|
67
|
+
minOutcomesPerArm: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What one arm actually did. */
|
|
71
|
+
export interface ExperimentArm {
|
|
72
|
+
name: string;
|
|
73
|
+
tally: OutcomeTally;
|
|
74
|
+
/** Everything this arm spent, recorded outcome or not. */
|
|
75
|
+
totalUsd: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ArmResult {
|
|
79
|
+
name: string;
|
|
80
|
+
successes: number;
|
|
81
|
+
/** Calls carrying a *declared* outcome — the denominator. */
|
|
82
|
+
recorded: number;
|
|
83
|
+
/** Successes over recorded, or null when nothing was recorded. */
|
|
84
|
+
rate: number | null;
|
|
85
|
+
/** Wilson score interval on that rate, or null. */
|
|
86
|
+
interval: { low: number; high: number } | null;
|
|
87
|
+
/** Spend on calls carrying a declared outcome. */
|
|
88
|
+
recordedUsd: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type Separation = 'a-wins' | 'b-wins' | 'not-separable';
|
|
92
|
+
|
|
93
|
+
export type NotSeparableReason =
|
|
94
|
+
| 'interval-includes-zero'
|
|
95
|
+
| 'no-difference-observed'
|
|
96
|
+
| 'nothing-recorded';
|
|
97
|
+
|
|
98
|
+
export interface Marginal {
|
|
99
|
+
/**
|
|
100
|
+
* What one extra success costs, going from the worse arm to the better one.
|
|
101
|
+
*
|
|
102
|
+
* `(dearer spend − cheaper spend) / (more successes − fewer successes)`,
|
|
103
|
+
* both per call so arms of different sizes compare. Null when the better arm
|
|
104
|
+
* is also the cheaper one, because then nothing is being bought and a
|
|
105
|
+
* "cost per extra success" would be a negative number people would quote.
|
|
106
|
+
*/
|
|
107
|
+
usdPerExtraSuccess: number | null;
|
|
108
|
+
/** The arm that resolved more, by rate. */
|
|
109
|
+
better: string;
|
|
110
|
+
/** Whether that arm also costs more per call. */
|
|
111
|
+
dearer: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ExperimentResult {
|
|
115
|
+
a: ArmResult;
|
|
116
|
+
b: ArmResult;
|
|
117
|
+
separation: Separation;
|
|
118
|
+
/** Why, when not separable. A refusal never arrives bare. */
|
|
119
|
+
notSeparable: NotSeparableReason | null;
|
|
120
|
+
/** Newcombe's interval on (rate A − rate B), or null. */
|
|
121
|
+
difference: { point: number; low: number; high: number } | null;
|
|
122
|
+
/**
|
|
123
|
+
* Outcomes **per arm** that would separate the observed difference, or null.
|
|
124
|
+
*
|
|
125
|
+
* Null when the arms recorded the same rate: no sample size separates a
|
|
126
|
+
* difference of zero, and returning a very large number would read as "keep
|
|
127
|
+
* going" when the honest answer is "there is nothing here to find".
|
|
128
|
+
*/
|
|
129
|
+
outcomesNeededPerArm: number | null;
|
|
130
|
+
stopping: {
|
|
131
|
+
declared: number;
|
|
132
|
+
/** Whether both arms cleared the declared minimum. */
|
|
133
|
+
honoured: boolean;
|
|
134
|
+
/** The arm that has not, when one has not. */
|
|
135
|
+
short: string | null;
|
|
136
|
+
};
|
|
137
|
+
marginal: Marginal | null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Wilson score interval — behaves at the sample sizes this will actually see. */
|
|
141
|
+
function wilson(successes: number, n: number, z = Z_95): { low: number; high: number } | null {
|
|
142
|
+
if (n <= 0) return null;
|
|
143
|
+
const p = successes / n;
|
|
144
|
+
const denominator = 1 + (z * z) / n;
|
|
145
|
+
const centre = (p + (z * z) / (2 * n)) / denominator;
|
|
146
|
+
const half = (z * Math.sqrt((p * (1 - p)) / n + (z * z) / (4 * n * n))) / denominator;
|
|
147
|
+
return { low: Math.max(0, centre - half), high: Math.min(1, centre + half) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resultOf(arm: ExperimentArm, vocabulary: OutcomeVocabulary | null): ArmResult {
|
|
151
|
+
const declared = vocabulary ?? { values: [], success: [] };
|
|
152
|
+
let successes = 0;
|
|
153
|
+
let recorded = 0;
|
|
154
|
+
let recordedUsd = 0;
|
|
155
|
+
for (const entry of arm.tally.byValue) {
|
|
156
|
+
// Undeclared values are out of both halves, as everywhere since 1.50.4: a
|
|
157
|
+
// typo in an exporter must not decide an experiment.
|
|
158
|
+
if (judgeOutcome(entry.value, declared) === 'undeclared') continue;
|
|
159
|
+
recorded += entry.calls;
|
|
160
|
+
recordedUsd += entry.usd;
|
|
161
|
+
if (declared.success.includes(entry.value)) successes += entry.calls;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
name: arm.name,
|
|
165
|
+
successes,
|
|
166
|
+
recorded,
|
|
167
|
+
rate: recorded > 0 ? successes / recorded : null,
|
|
168
|
+
interval: wilson(successes, recorded),
|
|
169
|
+
recordedUsd,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function runExperiment(
|
|
174
|
+
declaration: ExperimentDeclaration,
|
|
175
|
+
arms: { a: ExperimentArm; b: ExperimentArm },
|
|
176
|
+
vocabulary: OutcomeVocabulary | null,
|
|
177
|
+
): ExperimentResult {
|
|
178
|
+
const a = resultOf(arms.a, vocabulary);
|
|
179
|
+
const b = resultOf(arms.b, vocabulary);
|
|
180
|
+
|
|
181
|
+
const stopping = {
|
|
182
|
+
declared: declaration.minOutcomesPerArm,
|
|
183
|
+
honoured: a.recorded >= declaration.minOutcomesPerArm && b.recorded >= declaration.minOutcomesPerArm,
|
|
184
|
+
short:
|
|
185
|
+
a.recorded < declaration.minOutcomesPerArm
|
|
186
|
+
? a.name
|
|
187
|
+
: b.recorded < declaration.minOutcomesPerArm
|
|
188
|
+
? b.name
|
|
189
|
+
: null,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const bare = (reason: NotSeparableReason): ExperimentResult => ({
|
|
193
|
+
a,
|
|
194
|
+
b,
|
|
195
|
+
separation: 'not-separable',
|
|
196
|
+
notSeparable: reason,
|
|
197
|
+
difference: null,
|
|
198
|
+
outcomesNeededPerArm: null,
|
|
199
|
+
stopping,
|
|
200
|
+
marginal: null,
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (a.rate === null || b.rate === null || a.interval === null || b.interval === null) {
|
|
204
|
+
return bare('nothing-recorded');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Newcombe's interval on the difference, built from the two Wilson
|
|
209
|
+
* intervals rather than from a normal approximation to the difference.
|
|
210
|
+
*
|
|
211
|
+
* The naive interval is symmetric and can run past 0 or 1, which at the
|
|
212
|
+
* sample sizes a real experiment starts with is not an edge case — it is
|
|
213
|
+
* most of the first week.
|
|
214
|
+
*/
|
|
215
|
+
const point = a.rate - b.rate;
|
|
216
|
+
const low = point - Math.sqrt((a.rate - a.interval.low) ** 2 + (b.interval.high - b.rate) ** 2);
|
|
217
|
+
const high = point + Math.sqrt((a.interval.high - a.rate) ** 2 + (b.rate - b.interval.low) ** 2);
|
|
218
|
+
const difference = { point, low, high };
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* How many outcomes per arm would separate the difference *observed so far*.
|
|
222
|
+
*
|
|
223
|
+
* A standard two-proportion power calculation at 95% confidence and 80%
|
|
224
|
+
* power. It is an estimate about a difference that may itself be noise, and
|
|
225
|
+
* it is offered as "how much longer" rather than as a promise — but a number
|
|
226
|
+
* somebody can act on beats "not significant", which tells a reader nothing
|
|
227
|
+
* about whether to wait a day or abandon the idea.
|
|
228
|
+
*/
|
|
229
|
+
const spread = Math.abs(point);
|
|
230
|
+
const outcomesNeededPerArm =
|
|
231
|
+
spread === 0
|
|
232
|
+
? null
|
|
233
|
+
: Math.ceil(
|
|
234
|
+
(((Z_95 + Z_POWER_80) ** 2) * (a.rate * (1 - a.rate) + b.rate * (1 - b.rate))) /
|
|
235
|
+
(spread * spread),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const perCallA = arms.a.tally.parsed > 0 ? arms.a.totalUsd / arms.a.tally.parsed : 0;
|
|
239
|
+
const perCallB = arms.b.tally.parsed > 0 ? arms.b.totalUsd / arms.b.tally.parsed : 0;
|
|
240
|
+
const better = a.rate >= b.rate ? a : b;
|
|
241
|
+
const worse = better === a ? b : a;
|
|
242
|
+
const betterPerCall = better === a ? perCallA : perCallB;
|
|
243
|
+
const worsePerCall = better === a ? perCallB : perCallA;
|
|
244
|
+
const rateGap = (better.rate as number) - (worse.rate as number);
|
|
245
|
+
|
|
246
|
+
const marginal: Marginal = {
|
|
247
|
+
/**
|
|
248
|
+
* Per call on both sides, so arms that took different shares of the
|
|
249
|
+
* traffic compare. Dividing raw totals would report a marginal cost that
|
|
250
|
+
* moves when the split changes and the behaviour does not.
|
|
251
|
+
*/
|
|
252
|
+
usdPerExtraSuccess:
|
|
253
|
+
betterPerCall > worsePerCall && rateGap > 0
|
|
254
|
+
? (betterPerCall - worsePerCall) / rateGap
|
|
255
|
+
: null,
|
|
256
|
+
better: better.name,
|
|
257
|
+
dearer: betterPerCall > worsePerCall,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (spread === 0) return { ...bare('no-difference-observed'), difference, marginal };
|
|
261
|
+
if (low <= 0 && high >= 0) {
|
|
262
|
+
return { ...bare('interval-includes-zero'), difference, outcomesNeededPerArm, marginal };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
a,
|
|
267
|
+
b,
|
|
268
|
+
separation: point > 0 ? 'a-wins' : 'b-wins',
|
|
269
|
+
notSeparable: null,
|
|
270
|
+
difference,
|
|
271
|
+
outcomesNeededPerArm,
|
|
272
|
+
stopping,
|
|
273
|
+
marginal,
|
|
274
|
+
};
|
|
275
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -50,6 +50,31 @@ export type { PlanAction, PlanActionKind, PlanAssumption, PlanDocument } from '.
|
|
|
50
50
|
export { verifyPlan } from './verify.js';
|
|
51
51
|
export { buildHistory, storedReportFrom, MIN_RUN } from './history.js';
|
|
52
52
|
export { outcomeReport, judgeOutcome, OUTCOME_UNLOCKS } from './outcome.js';
|
|
53
|
+
export { runExperiment } from './experiment.js';
|
|
54
|
+
export type {
|
|
55
|
+
ArmResult,
|
|
56
|
+
ExperimentArm,
|
|
57
|
+
ExperimentDeclaration,
|
|
58
|
+
ExperimentResult,
|
|
59
|
+
Marginal,
|
|
60
|
+
NotSeparableReason,
|
|
61
|
+
Separation,
|
|
62
|
+
} from './experiment.js';
|
|
63
|
+
export {
|
|
64
|
+
ladderArithmetic,
|
|
65
|
+
ladderPosition,
|
|
66
|
+
validateLadder,
|
|
67
|
+
MIN_CALLS_FOR_LADDER,
|
|
68
|
+
BREAK_EVEN_BAND,
|
|
69
|
+
} from './ladder.js';
|
|
70
|
+
export type {
|
|
71
|
+
LadderArithmetic,
|
|
72
|
+
LadderPolicy,
|
|
73
|
+
LadderPosition,
|
|
74
|
+
LadderProblem,
|
|
75
|
+
LadderUnknown,
|
|
76
|
+
LadderVerdict,
|
|
77
|
+
} from './ladder.js';
|
|
53
78
|
export {
|
|
54
79
|
perOutcome,
|
|
55
80
|
rankPerOutcome,
|
package/src/ladder.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cheap first, escalate on measured failure — and the number that says whether
|
|
3
|
+
* that is a saving or a bill.
|
|
4
|
+
*
|
|
5
|
+
* "Route it to the cheaper model" has been a recommendation with a quality
|
|
6
|
+
* question attached since 1.23. Outcomes answered the quality question. This
|
|
7
|
+
* answers the one nobody asks out loud: **an escalation pays twice**, so a
|
|
8
|
+
* ladder is only a saving below a specific escalation rate, and above it the
|
|
9
|
+
* ladder costs more than never having built it.
|
|
10
|
+
*
|
|
11
|
+
* ## The arithmetic, stated rather than assumed
|
|
12
|
+
*
|
|
13
|
+
* Without a ladder, every call costs `dear`. With one, every call costs
|
|
14
|
+
* `cheap`, and the escalated share pays `dear` **on top** — the cheap attempt
|
|
15
|
+
* is not refunded.
|
|
16
|
+
*
|
|
17
|
+
* with a ladder: cheap + rate x dear
|
|
18
|
+
* without one: dear
|
|
19
|
+
*
|
|
20
|
+
* Those are equal at `rate = (dear - cheap) / dear`. Below it the ladder saves;
|
|
21
|
+
* above it the ladder is a more expensive way to get the same answers. A ladder
|
|
22
|
+
* sold as a saving without that number is the same head-arithmetic error `plan`
|
|
23
|
+
* was built to kill — and it is worse here, because the mistake compounds with
|
|
24
|
+
* traffic and nobody notices until a quarter is over.
|
|
25
|
+
*
|
|
26
|
+
* ## The escalation signal is the caller's
|
|
27
|
+
*
|
|
28
|
+
* Never inferred from length, latency, refusal text, a stop reason or a retry.
|
|
29
|
+
* The same refusal `outcome` makes, for a sharper reason: this is a **control
|
|
30
|
+
* loop**, not a report. A report built on a guess prints a wrong number; a
|
|
31
|
+
* control loop built on a guess sends real traffic to a more expensive model on
|
|
32
|
+
* the strength of that guess, forever, and bills for it.
|
|
33
|
+
*
|
|
34
|
+
* No signal, no ladder.
|
|
35
|
+
*
|
|
36
|
+
* ## What this module does not do
|
|
37
|
+
*
|
|
38
|
+
* It does not execute anything. A ladder escalates *after* a failure is known,
|
|
39
|
+
* which is after the answer came back and usually after something downstream
|
|
40
|
+
* judged it — so the retry belongs to the caller's own loop, not to a proxy
|
|
41
|
+
* sitting on one request. What lives here is the policy and the arithmetic that
|
|
42
|
+
* says whether the policy is worth running, measured against what actually
|
|
43
|
+
* happened.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import { judgeOutcome } from './outcome.js';
|
|
47
|
+
import type { OutcomeTally, OutcomeVocabulary } from './outcome.js';
|
|
48
|
+
import { effectivePricing } from './pricing.js';
|
|
49
|
+
import type { PricingCatalogue } from './pricing.js';
|
|
50
|
+
|
|
51
|
+
/** One workload's ladder, as the config declares it. */
|
|
52
|
+
export interface LadderPolicy {
|
|
53
|
+
/**
|
|
54
|
+
* Model ids, cheapest first. Two is the normal case; more is allowed and
|
|
55
|
+
* priced pairwise, because a three-rung ladder that escalates twice pays
|
|
56
|
+
* three times and the arithmetic has to say so.
|
|
57
|
+
*/
|
|
58
|
+
tiers: string[];
|
|
59
|
+
/**
|
|
60
|
+
* The recorded outcome values that send the work up a tier.
|
|
61
|
+
*
|
|
62
|
+
* Declared, like the vocabulary itself. A value here that the vocabulary
|
|
63
|
+
* never declared is a configuration error rather than a silent no-op, and
|
|
64
|
+
* `validateLadder` says so.
|
|
65
|
+
*/
|
|
66
|
+
escalateOn: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type LadderVerdict =
|
|
70
|
+
/** Measured escalation is below break-even: the ladder saves money. */
|
|
71
|
+
| 'saving'
|
|
72
|
+
/** Measured escalation is above it: the ladder costs money. */
|
|
73
|
+
| 'costing'
|
|
74
|
+
/** Within a hair of break-even, where the sign is not a claim worth making. */
|
|
75
|
+
| 'at-break-even'
|
|
76
|
+
/** Not enough was measured to say. */
|
|
77
|
+
| 'cannot-tell';
|
|
78
|
+
|
|
79
|
+
export type LadderUnknown =
|
|
80
|
+
| 'no-outcomes-recorded'
|
|
81
|
+
| 'no-escalation-values-declared'
|
|
82
|
+
| 'tier-unpriced'
|
|
83
|
+
| 'too-few-calls';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Calls a workload needs before its escalation rate is treated as a rate.
|
|
87
|
+
*
|
|
88
|
+
* The same floor `per-outcome` uses for successes, and for the same reason: a
|
|
89
|
+
* rate over fewer than ten observations moves more from one more observation
|
|
90
|
+
* than from anything a team could do about it. A control loop switched on
|
|
91
|
+
* because of nine calls is a control loop switched on for no reason.
|
|
92
|
+
*/
|
|
93
|
+
export const MIN_CALLS_FOR_LADDER = 10;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How close to break-even counts as break-even.
|
|
97
|
+
*
|
|
98
|
+
* Two percentage points. Inside that band the ladder's sign flips on ordinary
|
|
99
|
+
* week-to-week variation, and reporting "saving" on Monday and "costing" on
|
|
100
|
+
* Thursday from the same policy would teach a reader to ignore the figure.
|
|
101
|
+
*/
|
|
102
|
+
export const BREAK_EVEN_BAND = 0.02;
|
|
103
|
+
|
|
104
|
+
export interface LadderArithmetic {
|
|
105
|
+
/** What one call costs on the cheap tier. */
|
|
106
|
+
cheapUsd: number;
|
|
107
|
+
/** What one call costs on the dear tier. */
|
|
108
|
+
dearUsd: number;
|
|
109
|
+
/**
|
|
110
|
+
* The escalation rate at which the ladder stops saving, 0-1.
|
|
111
|
+
*
|
|
112
|
+
* `(dear - cheap) / dear`. Null when either tier could not be priced — never
|
|
113
|
+
* a zero, which would read as "any escalation at all loses money" and is a
|
|
114
|
+
* completely different and much more alarming claim.
|
|
115
|
+
*/
|
|
116
|
+
breakEvenRate: number | null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface LadderPosition {
|
|
120
|
+
arithmetic: LadderArithmetic;
|
|
121
|
+
/** The measured share of calls that escalated, 0-1, or null. */
|
|
122
|
+
measuredRate: number | null;
|
|
123
|
+
verdict: LadderVerdict;
|
|
124
|
+
/** Why, when the verdict is `cannot-tell`. A refusal never arrives bare. */
|
|
125
|
+
unknown: LadderUnknown | null;
|
|
126
|
+
/** Calls behind the measured rate. */
|
|
127
|
+
calls: number;
|
|
128
|
+
/** Calls whose recorded outcome was an escalation trigger. */
|
|
129
|
+
escalations: number;
|
|
130
|
+
/**
|
|
131
|
+
* What the ladder cost against what one tier alone would have, per call, or
|
|
132
|
+
* null when it cannot be computed. Negative means the ladder is cheaper.
|
|
133
|
+
*/
|
|
134
|
+
deltaUsdPerCall: number | null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The cost of one call on a model, for a described shape of work.
|
|
139
|
+
*
|
|
140
|
+
* Deliberately takes the token shape rather than reading it from a log: the
|
|
141
|
+
* break-even rate is a property of the *models and the work*, and somebody
|
|
142
|
+
* should be able to compute it before running a single call through a ladder.
|
|
143
|
+
*/
|
|
144
|
+
export function ladderArithmetic(
|
|
145
|
+
policy: LadderPolicy,
|
|
146
|
+
shape: { inputTokens: number; outputTokens: number },
|
|
147
|
+
catalogue: PricingCatalogue,
|
|
148
|
+
on: Date = new Date(),
|
|
149
|
+
): LadderArithmetic {
|
|
150
|
+
const priceOf = (id: string | undefined): number | null => {
|
|
151
|
+
if (id === undefined) return null;
|
|
152
|
+
const model = catalogue.byId.get(id);
|
|
153
|
+
if (model === undefined) return null;
|
|
154
|
+
const rates = effectivePricing(model, on);
|
|
155
|
+
return (
|
|
156
|
+
(shape.inputTokens / 1_000_000) * rates.inputPerMTok +
|
|
157
|
+
(shape.outputTokens / 1_000_000) * rates.outputPerMTok
|
|
158
|
+
);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const cheap = priceOf(policy.tiers[0]);
|
|
162
|
+
/**
|
|
163
|
+
* The **last** tier, not the second.
|
|
164
|
+
*
|
|
165
|
+
* A three-rung ladder's alternative is the model it would have used without
|
|
166
|
+
* one, which is the top rung. Comparing against the middle would report a
|
|
167
|
+
* saving against a model nobody was going to use.
|
|
168
|
+
*/
|
|
169
|
+
const dear = priceOf(policy.tiers[policy.tiers.length - 1]);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
cheapUsd: cheap ?? 0,
|
|
173
|
+
dearUsd: dear ?? 0,
|
|
174
|
+
breakEvenRate: cheap === null || dear === null || dear <= 0 ? null : (dear - cheap) / dear,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function ladderPosition(
|
|
179
|
+
policy: LadderPolicy,
|
|
180
|
+
tally: OutcomeTally,
|
|
181
|
+
shape: { inputTokens: number; outputTokens: number },
|
|
182
|
+
vocabulary: OutcomeVocabulary | null,
|
|
183
|
+
catalogue: PricingCatalogue,
|
|
184
|
+
on: Date = new Date(),
|
|
185
|
+
): LadderPosition {
|
|
186
|
+
const arithmetic = ladderArithmetic(policy, shape, catalogue, on);
|
|
187
|
+
const bare = (unknown: LadderUnknown): LadderPosition => ({
|
|
188
|
+
arithmetic,
|
|
189
|
+
measuredRate: null,
|
|
190
|
+
verdict: 'cannot-tell',
|
|
191
|
+
unknown,
|
|
192
|
+
calls: 0,
|
|
193
|
+
escalations: 0,
|
|
194
|
+
deltaUsdPerCall: null,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
if (arithmetic.breakEvenRate === null) return bare('tier-unpriced');
|
|
198
|
+
if (policy.escalateOn.length === 0) return bare('no-escalation-values-declared');
|
|
199
|
+
|
|
200
|
+
const declared = vocabulary ?? { values: [], success: [] };
|
|
201
|
+
let calls = 0;
|
|
202
|
+
let escalations = 0;
|
|
203
|
+
for (const entry of tally.byValue) {
|
|
204
|
+
// Undeclared values are out of the denominator as well as the numerator —
|
|
205
|
+
// a typo in an exporter must not move a control loop's break-even.
|
|
206
|
+
if (judgeOutcome(entry.value, declared) === 'undeclared') continue;
|
|
207
|
+
calls += entry.calls;
|
|
208
|
+
if (policy.escalateOn.includes(entry.value)) escalations += entry.calls;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (calls === 0) return bare('no-outcomes-recorded');
|
|
212
|
+
if (calls < MIN_CALLS_FOR_LADDER) {
|
|
213
|
+
return {
|
|
214
|
+
arithmetic,
|
|
215
|
+
measuredRate: null,
|
|
216
|
+
verdict: 'cannot-tell',
|
|
217
|
+
unknown: 'too-few-calls',
|
|
218
|
+
calls,
|
|
219
|
+
escalations,
|
|
220
|
+
deltaUsdPerCall: null,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const measuredRate = escalations / calls;
|
|
225
|
+
const withLadder = arithmetic.cheapUsd + measuredRate * arithmetic.dearUsd;
|
|
226
|
+
const deltaUsdPerCall = withLadder - arithmetic.dearUsd;
|
|
227
|
+
|
|
228
|
+
const distance = measuredRate - arithmetic.breakEvenRate;
|
|
229
|
+
const verdict: LadderVerdict =
|
|
230
|
+
Math.abs(distance) <= BREAK_EVEN_BAND ? 'at-break-even' : distance < 0 ? 'saving' : 'costing';
|
|
231
|
+
|
|
232
|
+
return { arithmetic, measuredRate, verdict, unknown: null, calls, escalations, deltaUsdPerCall };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export type LadderProblem =
|
|
236
|
+
| { kind: 'too-few-tiers'; tiers: number }
|
|
237
|
+
| { kind: 'unknown-model'; model: string }
|
|
238
|
+
| { kind: 'duplicate-tier'; model: string }
|
|
239
|
+
| { kind: 'tiers-not-cheapest-first'; model: string; after: string }
|
|
240
|
+
| { kind: 'escalate-on-undeclared'; value: string }
|
|
241
|
+
| { kind: 'escalate-on-a-success'; value: string };
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Everything wrong with a ladder before it is ever run.
|
|
245
|
+
*
|
|
246
|
+
* Returned rather than thrown, so a caller can report all of it at once — a
|
|
247
|
+
* config that has to be fixed one error per run is a config people give up on.
|
|
248
|
+
*
|
|
249
|
+
* The last two are the interesting ones. Escalating on a value the vocabulary
|
|
250
|
+
* never declared is a ladder that silently never fires; escalating on a value
|
|
251
|
+
* declared as a **success** is a ladder that pays twice for work that already
|
|
252
|
+
* worked, which is the most expensive possible typo in this file.
|
|
253
|
+
*/
|
|
254
|
+
export function validateLadder(
|
|
255
|
+
policy: LadderPolicy,
|
|
256
|
+
vocabulary: OutcomeVocabulary | null,
|
|
257
|
+
catalogue: PricingCatalogue,
|
|
258
|
+
on: Date = new Date(),
|
|
259
|
+
): LadderProblem[] {
|
|
260
|
+
const problems: LadderProblem[] = [];
|
|
261
|
+
if (policy.tiers.length < 2) {
|
|
262
|
+
problems.push({ kind: 'too-few-tiers', tiers: policy.tiers.length });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
let previous: { id: string; price: number } | null = null;
|
|
267
|
+
for (const id of policy.tiers) {
|
|
268
|
+
if (seen.has(id)) problems.push({ kind: 'duplicate-tier', model: id });
|
|
269
|
+
seen.add(id);
|
|
270
|
+
const model = catalogue.byId.get(id);
|
|
271
|
+
if (model === undefined) {
|
|
272
|
+
problems.push({ kind: 'unknown-model', model: id });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const price = effectivePricing(model, on).inputPerMTok;
|
|
276
|
+
if (previous !== null && price < previous.price) {
|
|
277
|
+
// A ladder whose rungs go down is not a ladder; it is a routing rule
|
|
278
|
+
// that escalates to something cheaper and then reports a saving for it.
|
|
279
|
+
problems.push({ kind: 'tiers-not-cheapest-first', model: id, after: previous.id });
|
|
280
|
+
}
|
|
281
|
+
previous = { id, price };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (vocabulary !== null) {
|
|
285
|
+
for (const value of policy.escalateOn) {
|
|
286
|
+
if (!vocabulary.values.includes(value)) {
|
|
287
|
+
problems.push({ kind: 'escalate-on-undeclared', value });
|
|
288
|
+
} else if (vocabulary.success.includes(value)) {
|
|
289
|
+
problems.push({ kind: 'escalate-on-a-success', value });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return problems;
|
|
295
|
+
}
|