@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.
@@ -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
+ }