@trazum/cli 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.50.5",
3
+ "version": "1.50.7",
4
4
  "description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "npm run build && npm test"
38
38
  },
39
39
  "dependencies": {
40
- "@trazum/core": "1.50.5"
40
+ "@trazum/core": "1.50.7"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -58,6 +58,8 @@ ${bold('USAGE')}
58
58
  trazum models
59
59
  trazum rules
60
60
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
61
+ trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
62
+ trazum ladder <log>
61
63
  trazum feedback
62
64
  trazum --version
63
65
 
@@ -118,6 +120,18 @@ ${bold('OPTIONS FOR gateway')}
118
120
 
119
121
  Your credential is forwarded untouched and never read. See docs/gateway.md.
120
122
 
123
+ ${bold('OPTIONS FOR experiment')}
124
+ --a <label>, --b <label> The two workloads to compare.
125
+ --min-outcomes <n> Required: how many outcomes each arm must record
126
+ before the result may be read. A stopping rule
127
+ declared after looking at the numbers is not a
128
+ stopping rule, and the report says whether it was
129
+ honoured.
130
+
131
+ Judges recorded outcomes and cost together. Three-valued: A wins, B wins, or
132
+ not separable — with the number of outcomes per arm that would settle it, so
133
+ "run it longer" is an instruction rather than a shrug. Nothing is promoted.
134
+
121
135
  ${bold('OPTIONS FOR prune')}
122
136
  --cases <file> One input per line, or a JSON array. Required.
123
137
  --yes Actually spend the calls. Without it the estimate is
@@ -597,6 +611,12 @@ ${bold('CONFIG FILE')}
597
611
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
598
612
  "trazum profile", in dollars. A budgeted label with no calls in
599
613
  the log is reported as not measured, never as a pass
614
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
615
+ "escalateOn": ["escalated"] } } — cheap model first, escalate a
616
+ recorded failure to a dearer one. Both fields required.
617
+ "trazum ladder <log>" prints the break-even escalation rate
618
+ beside the measured one: an escalation pays twice, so above that
619
+ rate the ladder costs more than never having built it
600
620
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
601
621
  your own vocabulary for what happened, and which of it counts as
602
622
  a win. Both required: which words mean success is a judgement
@@ -957,6 +977,73 @@ ${bold('EXAMPLES')}
957
977
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
958
978
  },
959
979
 
980
+ experiment: {
981
+ heading: (a, b) => `Experiment: ${a} against ${b}`,
982
+ needsTwo: () =>
983
+ 'Name two labels to compare, for example: trazum experiment <log> --a prompt-v1 --b prompt-v2',
984
+ needsRule: () =>
985
+ '--min-outcomes is required, and it is the point: a stopping rule declared after looking at the numbers is not a stopping rule. Say how many outcomes each arm must record before the result may be read.',
986
+ arm: (name, rate, successes, recorded, interval) =>
987
+ `${name} ${rate} (${successes} of ${recorded} recorded) 95% ${interval}`,
988
+ wins: (name, low, high) =>
989
+ `${name} wins. The difference is between ${low} and ${high} at 95% confidence \u2014 the whole interval is on one side of zero, which is what "wins" means here.`,
990
+ notSeparable: (why, needed) =>
991
+ why === 'no-difference-observed'
992
+ ? 'Not separable: both arms recorded the same rate. No sample size separates a difference of zero, so there is no "run it longer" to offer \u2014 there is nothing here to find.'
993
+ : why === 'nothing-recorded'
994
+ ? 'Not separable: an arm recorded no outcomes at all, so there is no rate to compare.'
995
+ : `Not separable on this traffic: the 95% interval on the difference includes zero. One number is larger, and that is not a finding. About ${needed} outcomes per arm would settle the difference observed so far.`,
996
+ peeked: (short, declared, recorded) =>
997
+ `Read early. The declared rule was ${declared} outcomes per arm and ${short} has ${recorded}. Nothing can stop a number being read early; this line exists so whoever reads the result later can see that it was.`,
998
+ honoured: (declared) => `Stopping rule honoured: both arms cleared ${declared} recorded outcomes.`,
999
+ marginalDearer: (better, usd) =>
1000
+ `${better} resolves more and costs more. One extra success costs ${usd} \u2014 that figure, not the rate, is what the decision turns on.`,
1001
+ marginalCheaper: (better) => `${better} resolves more AND costs less per call. Nothing is being traded.`,
1002
+ neverPromotes: () =>
1003
+ 'Nothing was changed. A winner is a finding; taking it is a decision with a name attached, and it belongs in the plan like everything else.',
1004
+ },
1005
+
1006
+ ladder: {
1007
+ heading: () => 'Escalation ladders',
1008
+ noLadders: () =>
1009
+ 'No ladders configured. A ladder sends a workload to a cheap model first and escalates a recorded failure to a dearer one — add "ladders" to trazum.config.json, for example {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
1010
+ workload: (label) => label,
1011
+ arithmetic: (cheap, dear, breakEven) =>
1012
+ `${cheap} a call cheap, ${dear} dear. Break-even escalation rate: ${breakEven}.`,
1013
+ measured: (rate, escalations, calls) =>
1014
+ `Measured: ${rate} (${escalations} of ${calls} calls escalated).`,
1015
+ saving: (delta) => `Saving ${delta} a call against never having built it.`,
1016
+ costing: (delta) =>
1017
+ `Costing ${delta} a call MORE than never having built it. Escalating above the break-even rate means paying for the cheap attempt and the dear one on most calls.`,
1018
+ atBreakEven: (band) =>
1019
+ `Within ${band} of break-even, so no sign is claimed. Inside that band the answer flips on ordinary week-to-week variation, and "saving" on Monday and "costing" on Thursday from the same policy teaches a reader to ignore the figure.`,
1020
+ cannotTell: (why, calls) =>
1021
+ why === 'too-few-calls'
1022
+ ? `Cannot tell yet: ${calls} calls carried a declared outcome, and a rate over that few moves more from one more call than from anything you could do about it.`
1023
+ : why === 'no-outcomes-recorded'
1024
+ ? 'Cannot tell: nothing in this log recorded an outcome for this workload, so there is no escalation rate to compare against.'
1025
+ : why === 'tier-unpriced'
1026
+ ? 'Cannot tell: one of the tiers is not in the price catalogue, so the break-even rate cannot be computed.'
1027
+ : 'Cannot tell: this ladder declares no escalation values.',
1028
+ problemsHeading: (label) => `${label} — this ladder will not do what it looks like it does`,
1029
+ problem: (kind, detail) =>
1030
+ kind === 'escalate-on-a-success'
1031
+ ? `escalateOn names "${detail}", which "outcomes.success" declares a SUCCESS. This ladder pays twice for work that already worked, on every call, while looking exactly like a cost-saving measure.`
1032
+ : kind === 'escalate-on-undeclared'
1033
+ ? `escalateOn names "${detail}", which "outcomes.values" does not declare. This ladder never fires, silently.`
1034
+ : kind === 'tiers-not-cheapest-first'
1035
+ ? `"${detail}" is cheaper than the tier before it. That is not a ladder; it is a routing rule that escalates to something cheaper and reports a saving for it.`
1036
+ : kind === 'duplicate-tier'
1037
+ ? `"${detail}" appears twice in tiers.`
1038
+ : kind === 'unknown-model'
1039
+ ? `"${detail}" is not in the price catalogue.`
1040
+ : `a ladder needs at least two tiers; this one has ${detail}.`,
1041
+ theDoubleSpend: () =>
1042
+ 'An escalation pays twice: the cheap attempt is not refunded. So a ladder saves money only below its break-even escalation rate, and above it costs more than never having built one — which is why the rate is printed beside the measurement rather than left to be worked out in somebody\u2019s head.',
1043
+ notExecuted: () =>
1044
+ 'Trazum does not run the escalation. A ladder escalates after a failure is known, which is after the answer came back and usually after something downstream judged it, so the retry belongs in your own loop. What is here is the policy and the arithmetic that says whether the policy is worth running.',
1045
+ },
1046
+
960
1047
  gateway: {
961
1048
  badProvider: (given, known) =>
962
1049
  given === ''
package/src/i18n/es.ts CHANGED
@@ -45,6 +45,8 @@ ${bold('USO')}
45
45
  trazum models
46
46
  trazum rules
47
47
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
48
+ trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
49
+ trazum ladder <log>
48
50
  trazum feedback
49
51
  trazum --version
50
52
 
@@ -108,6 +110,18 @@ ${bold('OPCIONES DE gateway')}
108
110
 
109
111
  Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
110
112
 
113
+ ${bold('OPCIONES DE experiment')}
114
+ --a <etiqueta>, --b <etiqueta> Las dos cargas a comparar.
115
+ --min-outcomes <n> Obligatorio: cuántos resultados debe registrar
116
+ cada brazo antes de poder leer el resultado. Una
117
+ regla de parada declarada después de mirar los
118
+ números no es una regla de parada, y el informe
119
+ dice si se respetó.
120
+
121
+ Juzga resultados registrados y coste a la vez. Tres valores: gana A, gana B,
122
+ o no separables — con cuántos resultados por brazo lo zanjarían, para que
123
+ "déjalo correr más" sea una instrucción y no un encogimiento de hombros.
124
+
111
125
  ${bold('OPCIONES DE prune')}
112
126
  --cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
113
127
  --yes Gasta las llamadas de verdad. Sin él se imprime la
@@ -617,6 +631,12 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
617
631
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
618
632
  dólares para "trazum profile". Una etiqueta con presupuesto y sin
619
633
  llamadas se informa como no medida, nunca como aprobada
634
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
635
+ "escalateOn": ["escalated"] } } — modelo barato primero, escalar
636
+ un fallo registrado a uno más caro. Los dos campos obligatorios.
637
+ "trazum ladder <log>" imprime la tasa de escalado de equilibrio
638
+ junto a la medida: un escalado paga dos veces, así que por encima
639
+ de esa tasa la escalera cuesta más que no haberla construido
620
640
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
621
641
  tu propio vocabulario para lo que pasó, y cuál de él cuenta como
622
642
  acierto. Los dos son obligatorios: qué palabras significan éxito
@@ -993,6 +1013,73 @@ ${bold('EJEMPLOS')}
993
1013
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
994
1014
  },
995
1015
 
1016
+ experiment: {
1017
+ heading: (a, b) => `Experimento: ${a} contra ${b}`,
1018
+ needsTwo: () =>
1019
+ 'Nombra dos etiquetas a comparar, por ejemplo: trazum experiment <log> --a prompt-v1 --b prompt-v2',
1020
+ needsRule: () =>
1021
+ '--min-outcomes es obligatorio, y ese es justo el punto: una regla de parada declarada despu\u00e9s de mirar los n\u00fameros no es una regla de parada. Di cu\u00e1ntos resultados debe registrar cada brazo antes de poder leer el resultado.',
1022
+ arm: (name, rate, successes, recorded, interval) =>
1023
+ `${name} ${rate} (${successes} de ${recorded} registrados) 95% ${interval}`,
1024
+ wins: (name, low, high) =>
1025
+ `Gana ${name}. La diferencia est\u00e1 entre ${low} y ${high} con un 95% de confianza \u2014 el intervalo entero est\u00e1 a un lado del cero, que es lo que significa "gana" aqu\u00ed.`,
1026
+ notSeparable: (why, needed) =>
1027
+ why === 'no-difference-observed'
1028
+ ? 'No separables: los dos brazos registraron la misma tasa. Ning\u00fan tama\u00f1o de muestra separa una diferencia de cero, as\u00ed que no hay ning\u00fan "d\u00e9jalo correr m\u00e1s" que ofrecer \u2014 no hay nada que encontrar.'
1029
+ : why === 'nothing-recorded'
1030
+ ? 'No separables: un brazo no registr\u00f3 ning\u00fan resultado, as\u00ed que no hay tasa que comparar.'
1031
+ : `No separables con este tr\u00e1fico: el intervalo del 95% sobre la diferencia incluye el cero. Un n\u00famero es mayor, y eso no es un hallazgo. Unos ${needed} resultados por brazo zanjar\u00edan la diferencia observada hasta ahora.`,
1032
+ peeked: (short, declared, recorded) =>
1033
+ `Le\u00eddo antes de tiempo. La regla declarada eran ${declared} resultados por brazo y ${short} tiene ${recorded}. Nada puede impedir que se lea un n\u00famero antes de tiempo; esta l\u00ednea existe para que quien lea el resultado despu\u00e9s vea que se hizo.`,
1034
+ honoured: (declared) => `Regla de parada respetada: los dos brazos superaron ${declared} resultados registrados.`,
1035
+ marginalDearer: (better, usd) =>
1036
+ `${better} resuelve m\u00e1s y cuesta m\u00e1s. Un acierto extra cuesta ${usd} \u2014 esa cifra, y no la tasa, es de lo que depende la decisi\u00f3n.`,
1037
+ marginalCheaper: (better) => `${better} resuelve m\u00e1s Y cuesta menos por llamada. No se est\u00e1 cambiando nada por nada.`,
1038
+ neverPromotes: () =>
1039
+ 'No se cambi\u00f3 nada. Un ganador es un hallazgo; tomarlo es una decisi\u00f3n con un nombre detr\u00e1s, y va en el plan como todo lo dem\u00e1s.',
1040
+ },
1041
+
1042
+ ladder: {
1043
+ heading: () => 'Escaleras de escalado',
1044
+ noLadders: () =>
1045
+ 'No hay escaleras configuradas. Una escalera manda una carga a un modelo barato primero y escala un fallo registrado a uno m\u00e1s caro — a\u00f1ade "ladders" a trazum.config.json, por ejemplo {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
1046
+ workload: (label) => label,
1047
+ arithmetic: (cheap, dear, breakEven) =>
1048
+ `${cheap} por llamada barata, ${dear} cara. Tasa de escalado de equilibrio: ${breakEven}.`,
1049
+ measured: (rate, escalations, calls) =>
1050
+ `Medido: ${rate} (${escalations} de ${calls} llamadas escalaron).`,
1051
+ saving: (delta) => `Ahorra ${delta} por llamada frente a no haberla construido.`,
1052
+ costing: (delta) =>
1053
+ `Cuesta ${delta} por llamada M\u00c1S que no haberla construido. Escalar por encima de la tasa de equilibrio significa pagar el intento barato y el caro en la mayor\u00eda de las llamadas.`,
1054
+ atBreakEven: (band) =>
1055
+ `A menos de ${band} del equilibrio, as\u00ed que no se afirma ning\u00fan signo. Dentro de esa banda la respuesta cambia con la variaci\u00f3n normal de una semana a otra, y decir "ahorra" el lunes y "cuesta" el jueves con la misma pol\u00edtica ense\u00f1a a ignorar la cifra.`,
1056
+ cannotTell: (why, calls) =>
1057
+ why === 'too-few-calls'
1058
+ ? `A\u00fan no se puede decir: ${calls} llamadas llevaron un resultado declarado, y una tasa sobre tan pocas se mueve m\u00e1s con una llamada m\u00e1s que con nada que pudieras hacer.`
1059
+ : why === 'no-outcomes-recorded'
1060
+ ? 'No se puede decir: nada en este log registr\u00f3 un resultado para esta carga, as\u00ed que no hay tasa de escalado con la que comparar.'
1061
+ : why === 'tier-unpriced'
1062
+ ? 'No se puede decir: uno de los pelda\u00f1os no est\u00e1 en la tabla de precios, as\u00ed que la tasa de equilibrio no se puede calcular.'
1063
+ : 'No se puede decir: esta escalera no declara valores de escalado.',
1064
+ problemsHeading: (label) => `${label} — esta escalera no va a hacer lo que parece`,
1065
+ problem: (kind, detail) =>
1066
+ kind === 'escalate-on-a-success'
1067
+ ? `escalateOn nombra "${detail}", que "outcomes.success" declara como \u00c9XITO. Esta escalera paga dos veces por trabajo que ya funcion\u00f3, en cada llamada, con el aspecto exacto de una medida de ahorro.`
1068
+ : kind === 'escalate-on-undeclared'
1069
+ ? `escalateOn nombra "${detail}", que "outcomes.values" no declara. Esta escalera no se dispara nunca, en silencio.`
1070
+ : kind === 'tiers-not-cheapest-first'
1071
+ ? `"${detail}" es m\u00e1s barato que el pelda\u00f1o anterior. Eso no es una escalera; es una regla de enrutado que escala a algo m\u00e1s barato y lo reporta como ahorro.`
1072
+ : kind === 'duplicate-tier'
1073
+ ? `"${detail}" aparece dos veces en tiers.`
1074
+ : kind === 'unknown-model'
1075
+ ? `"${detail}" no est\u00e1 en la tabla de precios.`
1076
+ : `una escalera necesita al menos dos pelda\u00f1os; esta tiene ${detail}.`,
1077
+ theDoubleSpend: () =>
1078
+ 'Un escalado paga dos veces: el intento barato no se devuelve. As\u00ed que una escalera solo ahorra por debajo de su tasa de escalado de equilibrio, y por encima cuesta m\u00e1s que no haberla construido — por eso la tasa se imprime junto a la medici\u00f3n en vez de dejarla para calcularla de cabeza.',
1079
+ notExecuted: () =>
1080
+ 'Trazum no ejecuta el escalado. Una escalera escala despu\u00e9s de conocer un fallo, que es despu\u00e9s de que llegue la respuesta y normalmente despu\u00e9s de que algo la juzgue, as\u00ed que el reintento va en tu propio bucle. Lo que hay aqu\u00ed es la pol\u00edtica y la aritm\u00e9tica que dice si merece la pena.',
1081
+ },
1082
+
996
1083
  gateway: {
997
1084
  badProvider: (given, known) =>
998
1085
  given === ''
package/src/i18n/types.ts CHANGED
@@ -264,6 +264,40 @@ export interface CliMessages {
264
264
  * between somebody and their provider is trusted on nothing but what it says
265
265
  * plainly at start-up.
266
266
  */
267
+ /**
268
+ * The ladder. Every line here exists to stop somebody reading "we route to
269
+ * the cheap model first" as a saving without the number that decides it.
270
+ */
271
+ experiment: {
272
+ heading(a: string, b: string): string;
273
+ needsTwo(): string;
274
+ needsRule(): string;
275
+ arm(name: string, rate: string, successes: string, recorded: string, interval: string): string;
276
+ wins(name: string, low: string, high: string): string;
277
+ notSeparable(why: string, needed: string): string;
278
+ peeked(short: string, declared: string, recorded: string): string;
279
+ honoured(declared: string): string;
280
+ marginalDearer(better: string, usd: string): string;
281
+ marginalCheaper(better: string): string;
282
+ neverPromotes(): string;
283
+ };
284
+
285
+ ladder: {
286
+ heading(): string;
287
+ noLadders(): string;
288
+ workload(label: string): string;
289
+ arithmetic(cheap: string, dear: string, breakEven: string): string;
290
+ measured(rate: string, escalations: string, calls: string): string;
291
+ saving(delta: string): string;
292
+ costing(delta: string): string;
293
+ atBreakEven(band: string): string;
294
+ cannotTell(why: string, calls: string): string;
295
+ problem(kind: string, detail: string): string;
296
+ problemsHeading(label: string): string;
297
+ theDoubleSpend(): string;
298
+ notExecuted(): string;
299
+ };
300
+
267
301
  gateway: {
268
302
  badProvider(given: string, known: string): string;
269
303
  needsPolicy(policies: string): string;
package/src/index.ts CHANGED
@@ -39,6 +39,10 @@ import {
39
39
  DEFAULT_USAGE,
40
40
  budgetPositions,
41
41
  conform,
42
+ BREAK_EVEN_BAND,
43
+ runExperiment,
44
+ ladderPosition,
45
+ validateLadder,
42
46
  outcomeReport,
43
47
  rankPerOutcome,
44
48
  FAILURE_POLICIES,
@@ -136,6 +140,7 @@ import type {
136
140
  import type {
137
141
  BudgetReport,
138
142
  ContractName,
143
+ ExperimentArm,
139
144
  FailurePolicy,
140
145
  GatewayStanding,
141
146
  UsageProfileReport,
@@ -242,6 +247,9 @@ interface Args {
242
247
  }
243
248
 
244
249
  const VALUE_FLAGS = new Set([
250
+ 'a',
251
+ 'b',
252
+ 'min-outcomes',
245
253
  'against',
246
254
  'contract',
247
255
  'on-cannot-tell',
@@ -570,6 +578,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
570
578
  conform: ['contract', 'json'],
571
579
  feedback: [],
572
580
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
581
+ ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
582
+ experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
573
583
  where: [],
574
584
  rules: [],
575
585
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2190,6 +2200,265 @@ async function commandGateway(
2190
2200
  console.log();
2191
2201
  }
2192
2202
 
2203
+ /**
2204
+ * `trazum ladder <log>` — is the ladder saving money, or is it a bill?
2205
+ *
2206
+ * The one number this command exists to print is the **break-even escalation
2207
+ * rate**. "We route to the cheap model first" describes a policy that saves
2208
+ * money and a policy that costs money equally well; only the rate separates
2209
+ * them, and nobody works it out in their head because the shape of the
2210
+ * arithmetic is not obvious — an escalation pays twice, since the cheap
2211
+ * attempt is not refunded.
2212
+ */
2213
+ async function commandLadder(
2214
+ args: Args,
2215
+ config: TrazumConfig,
2216
+ pricing: PricingCatalogue,
2217
+ t: CliMessages,
2218
+ ): Promise<void> {
2219
+ const path = args.positional[0];
2220
+ if (path === undefined) {
2221
+ throw new Error(t.errors.missingInputFile());
2222
+ }
2223
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
2224
+ const ladders = config.ladders ?? {};
2225
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2226
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2227
+
2228
+ console.log();
2229
+ console.log(c.bold(t.ladder.heading()));
2230
+ if (Object.keys(ladders).length === 0) {
2231
+ console.log(` ${c.dim(wrap(t.ladder.noLadders(), 74, ' '))}`);
2232
+ console.log();
2233
+ return;
2234
+ }
2235
+ console.log(` ${c.dim(wrap(t.ladder.theDoubleSpend(), 74, ' '))}`);
2236
+ console.log();
2237
+
2238
+ const vocabulary = config.outcomes ?? null;
2239
+ let anyProblem = false;
2240
+
2241
+ for (const [label, policy] of Object.entries(ladders)) {
2242
+ /**
2243
+ * Validated before it is measured, and loudly.
2244
+ *
2245
+ * A ladder that escalates on a value declared a *success* pays twice for
2246
+ * work that already worked, on every call, while looking exactly like a
2247
+ * cost-saving measure in the config. Printing its measured position first
2248
+ * would bury that under a number.
2249
+ */
2250
+ const problems = validateLadder(policy, vocabulary, pricing);
2251
+ if (problems.length > 0) {
2252
+ anyProblem = true;
2253
+ console.log(` ${c.red('✗')} ${c.bold(t.ladder.problemsHeading(label))}`);
2254
+ for (const problem of problems) {
2255
+ const detail =
2256
+ 'value' in problem
2257
+ ? problem.value
2258
+ : 'model' in problem
2259
+ ? problem.model
2260
+ : String(problem.tiers);
2261
+ console.log(` ${wrap(t.ladder.problem(problem.kind, detail), 70, ' ')}`);
2262
+ }
2263
+ console.log();
2264
+ continue;
2265
+ }
2266
+
2267
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
2268
+ const breakdown = report.byLabel.find((entry) => entry.label === label);
2269
+ /**
2270
+ * The shape of the work comes from the measured calls, so the break-even
2271
+ * rate is priced against what this workload actually sends rather than
2272
+ * against a token count somebody guessed at.
2273
+ */
2274
+ const calls = breakdown?.breakdown.calls ?? 0;
2275
+ const shape =
2276
+ breakdown === undefined || calls === 0
2277
+ ? { inputTokens: 0, outputTokens: 0 }
2278
+ : {
2279
+ inputTokens: Math.round(
2280
+ (breakdown.breakdown.inputTokens +
2281
+ breakdown.breakdown.cacheReadTokens +
2282
+ breakdown.breakdown.cacheWriteTokens) /
2283
+ calls,
2284
+ ),
2285
+ outputTokens: Math.round(breakdown.breakdown.outputTokens / calls),
2286
+ };
2287
+
2288
+ const empty = { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 };
2289
+ const position = ladderPosition(policy, slice?.tally ?? empty, shape, vocabulary, pricing);
2290
+
2291
+ console.log(` ${c.bold(t.ladder.workload(label))} ${c.dim(policy.tiers.join(' → '))}`);
2292
+ console.log(
2293
+ ` ${c.dim(
2294
+ t.ladder.arithmetic(
2295
+ formatUsd(position.arithmetic.cheapUsd),
2296
+ formatUsd(position.arithmetic.dearUsd),
2297
+ position.arithmetic.breakEvenRate === null ? '—' : pct(position.arithmetic.breakEvenRate),
2298
+ ),
2299
+ )}`,
2300
+ );
2301
+
2302
+ if (position.verdict === 'cannot-tell') {
2303
+ console.log(
2304
+ ` ${c.yellow('?')} ${wrap(t.ladder.cannotTell(position.unknown ?? '', n(position.calls)), 70, ' ')}`,
2305
+ );
2306
+ } else {
2307
+ console.log(
2308
+ ` ${t.ladder.measured(pct(position.measuredRate ?? 0), n(position.escalations), n(position.calls))}`,
2309
+ );
2310
+ const delta = formatUsd(Math.abs(position.deltaUsdPerCall ?? 0));
2311
+ if (position.verdict === 'saving') {
2312
+ console.log(` ${c.green('✓')} ${wrap(t.ladder.saving(delta), 70, ' ')}`);
2313
+ } else if (position.verdict === 'costing') {
2314
+ console.log(` ${c.red('✗')} ${wrap(t.ladder.costing(delta), 70, ' ')}`);
2315
+ } else {
2316
+ console.log(` ${c.dim('·')} ${wrap(t.ladder.atBreakEven(pct(BREAK_EVEN_BAND)), 70, ' ')}`);
2317
+ }
2318
+ }
2319
+ console.log();
2320
+ }
2321
+
2322
+ console.log(` ${c.dim(wrap(t.ladder.notExecuted(), 74, ' '))}`);
2323
+ console.log();
2324
+
2325
+ /**
2326
+ * A misconfigured ladder fails the command, because it is the one finding
2327
+ * here that is wrong *now* rather than a measurement somebody should look
2328
+ * at. Everything else exits 0: this is a survey, like `doctor`.
2329
+ */
2330
+ if (anyProblem) process.exitCode = 1;
2331
+ }
2332
+
2333
+ /**
2334
+ * `trazum experiment <log> --a <label> --b <label> --min-outcomes <n>`
2335
+ *
2336
+ * Two arms on real traffic, judged on recorded outcomes and cost together.
2337
+ *
2338
+ * `--min-outcomes` is required and that is the entire point of it. A stopping
2339
+ * rule declared after looking at the numbers is not a stopping rule, and
2340
+ * nothing here can stop somebody reading a result early — what it can do is
2341
+ * make the early read **visible to whoever reads the result later**, which is
2342
+ * the part that survives the afternoon.
2343
+ */
2344
+ async function commandExperiment(
2345
+ args: Args,
2346
+ config: TrazumConfig,
2347
+ pricing: PricingCatalogue,
2348
+ t: CliMessages,
2349
+ ): Promise<void> {
2350
+ const path = args.positional[0];
2351
+ if (path === undefined) throw new Error(t.errors.missingInputFile());
2352
+
2353
+ const aName = stringFlag(args, 'a');
2354
+ const bName = stringFlag(args, 'b');
2355
+ if (aName === undefined || bName === undefined) throw new Error(t.experiment.needsTwo());
2356
+
2357
+ const minRaw = stringFlag(args, 'min-outcomes');
2358
+ const minOutcomesPerArm = minRaw === undefined ? Number.NaN : Number(minRaw);
2359
+ if (!Number.isInteger(minOutcomesPerArm) || minOutcomesPerArm < 1) {
2360
+ throw new Error(t.experiment.needsRule());
2361
+ }
2362
+
2363
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
2364
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2365
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2366
+
2367
+ const armOf = (label: string): ExperimentArm => {
2368
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
2369
+ return {
2370
+ name: label,
2371
+ totalUsd: slice?.totalUsd ?? 0,
2372
+ tally: slice?.tally ?? { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 },
2373
+ };
2374
+ };
2375
+
2376
+ const result = runExperiment(
2377
+ { arms: [aName, bName], minOutcomesPerArm },
2378
+ { a: armOf(aName), b: armOf(bName) },
2379
+ config.outcomes ?? null,
2380
+ );
2381
+
2382
+ console.log();
2383
+ console.log(c.bold(t.experiment.heading(aName, bName)));
2384
+ console.log();
2385
+ for (const side of [result.a, result.b]) {
2386
+ console.log(
2387
+ ` ${t.experiment.arm(
2388
+ side.name,
2389
+ side.rate === null ? '—' : pct(side.rate),
2390
+ n(side.successes),
2391
+ n(side.recorded),
2392
+ side.interval === null ? '—' : `[${pct(side.interval.low)}, ${pct(side.interval.high)}]`,
2393
+ )}`,
2394
+ );
2395
+ }
2396
+ console.log();
2397
+
2398
+ if (result.separation === 'not-separable') {
2399
+ console.log(
2400
+ ` ${c.dim('·')} ${wrap(
2401
+ t.experiment.notSeparable(
2402
+ result.notSeparable ?? '',
2403
+ result.outcomesNeededPerArm === null ? '—' : n(result.outcomesNeededPerArm),
2404
+ ),
2405
+ 74,
2406
+ ' ',
2407
+ )}`,
2408
+ );
2409
+ } else {
2410
+ const winner = result.separation === 'a-wins' ? result.a.name : result.b.name;
2411
+ const d = result.difference as { low: number; high: number };
2412
+ // Reported as a magnitude: the sign is carried by which arm is named, and
2413
+ // printing "-30.0% to -18.0%" beside "b wins" is two ways of saying the
2414
+ // same thing that a reader has to reconcile.
2415
+ const lo = Math.min(Math.abs(d.low), Math.abs(d.high));
2416
+ const hi = Math.max(Math.abs(d.low), Math.abs(d.high));
2417
+ console.log(` ${c.green('✓')} ${wrap(t.experiment.wins(winner, pct(lo), pct(hi)), 74, ' ')}`);
2418
+ }
2419
+
2420
+ /**
2421
+ * The peek line, printed **whether or not** the arms separated.
2422
+ *
2423
+ * A separable result read too early is still separable and still read too
2424
+ * early. Collapsing the two would hide one of the facts, and it is always
2425
+ * the inconvenient one that goes.
2426
+ */
2427
+ console.log();
2428
+ if (result.stopping.honoured) {
2429
+ console.log(` ${c.dim(wrap(t.experiment.honoured(n(result.stopping.declared)), 74, ' '))}`);
2430
+ } else {
2431
+ const short = result.stopping.short === result.a.name ? result.a : result.b;
2432
+ console.log(
2433
+ ` ${c.yellow('!')} ${wrap(
2434
+ t.experiment.peeked(short.name, n(result.stopping.declared), n(short.recorded)),
2435
+ 74,
2436
+ ' ',
2437
+ )}`,
2438
+ );
2439
+ }
2440
+
2441
+ if (result.marginal !== null) {
2442
+ console.log();
2443
+ console.log(
2444
+ ` ${wrap(
2445
+ result.marginal.usdPerExtraSuccess !== null
2446
+ ? t.experiment.marginalDearer(
2447
+ result.marginal.better,
2448
+ formatUsd(result.marginal.usdPerExtraSuccess),
2449
+ )
2450
+ : t.experiment.marginalCheaper(result.marginal.better),
2451
+ 74,
2452
+ ' ',
2453
+ )}`,
2454
+ );
2455
+ }
2456
+
2457
+ console.log();
2458
+ console.log(` ${c.dim(wrap(t.experiment.neverPromotes(), 74, ' '))}`);
2459
+ console.log();
2460
+ }
2461
+
2193
2462
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2194
2463
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2195
2464
  const col = t.models.columns;
@@ -8390,6 +8659,12 @@ async function main(): Promise<void> {
8390
8659
  case 'models':
8391
8660
  commandModels(t, pricing);
8392
8661
  break;
8662
+ case 'experiment':
8663
+ await commandExperiment(args, config, pricing, t);
8664
+ break;
8665
+ case 'ladder':
8666
+ await commandLadder(args, config, pricing, t);
8667
+ break;
8393
8668
  case 'gateway':
8394
8669
  await commandGateway(args, config, configDir, pricing, t);
8395
8670
  break;