@trazum/cli 1.50.4 → 1.50.6

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.4",
3
+ "version": "1.50.6",
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.4"
40
+ "@trazum/core": "1.50.6"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -58,6 +58,7 @@ ${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 ladder <log>
61
62
  trazum feedback
62
63
  trazum --version
63
64
 
@@ -597,6 +598,12 @@ ${bold('CONFIG FILE')}
597
598
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
598
599
  "trazum profile", in dollars. A budgeted label with no calls in
599
600
  the log is reported as not measured, never as a pass
601
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
602
+ "escalateOn": ["escalated"] } } — cheap model first, escalate a
603
+ recorded failure to a dearer one. Both fields required.
604
+ "trazum ladder <log>" prints the break-even escalation rate
605
+ beside the measured one: an escalation pays twice, so above that
606
+ rate the ladder costs more than never having built it
600
607
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
601
608
  your own vocabulary for what happened, and which of it counts as
602
609
  a win. Both required: which words mean success is a judgement
@@ -957,6 +964,47 @@ ${bold('EXAMPLES')}
957
964
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
958
965
  },
959
966
 
967
+ ladder: {
968
+ heading: () => 'Escalation ladders',
969
+ noLadders: () =>
970
+ '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"]}}.',
971
+ workload: (label) => label,
972
+ arithmetic: (cheap, dear, breakEven) =>
973
+ `${cheap} a call cheap, ${dear} dear. Break-even escalation rate: ${breakEven}.`,
974
+ measured: (rate, escalations, calls) =>
975
+ `Measured: ${rate} (${escalations} of ${calls} calls escalated).`,
976
+ saving: (delta) => `Saving ${delta} a call against never having built it.`,
977
+ costing: (delta) =>
978
+ `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.`,
979
+ atBreakEven: (band) =>
980
+ `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.`,
981
+ cannotTell: (why, calls) =>
982
+ why === 'too-few-calls'
983
+ ? `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.`
984
+ : why === 'no-outcomes-recorded'
985
+ ? 'Cannot tell: nothing in this log recorded an outcome for this workload, so there is no escalation rate to compare against.'
986
+ : why === 'tier-unpriced'
987
+ ? 'Cannot tell: one of the tiers is not in the price catalogue, so the break-even rate cannot be computed.'
988
+ : 'Cannot tell: this ladder declares no escalation values.',
989
+ problemsHeading: (label) => `${label} — this ladder will not do what it looks like it does`,
990
+ problem: (kind, detail) =>
991
+ kind === 'escalate-on-a-success'
992
+ ? `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.`
993
+ : kind === 'escalate-on-undeclared'
994
+ ? `escalateOn names "${detail}", which "outcomes.values" does not declare. This ladder never fires, silently.`
995
+ : kind === 'tiers-not-cheapest-first'
996
+ ? `"${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.`
997
+ : kind === 'duplicate-tier'
998
+ ? `"${detail}" appears twice in tiers.`
999
+ : kind === 'unknown-model'
1000
+ ? `"${detail}" is not in the price catalogue.`
1001
+ : `a ladder needs at least two tiers; this one has ${detail}.`,
1002
+ theDoubleSpend: () =>
1003
+ '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.',
1004
+ notExecuted: () =>
1005
+ '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.',
1006
+ },
1007
+
960
1008
  gateway: {
961
1009
  badProvider: (given, known) =>
962
1010
  given === ''
@@ -1653,6 +1701,30 @@ ${bold('EXAMPLES')}
1653
1701
  `${share} of the bill (${usd}) carried no outcome, and is in neither half of the rate above.`,
1654
1702
  outcomeUndeclared: (values) =>
1655
1703
  `Not declared in "outcomes.values": ${values}. Named rather than counted as failures \u2014 a typo in an exporter should look like a typo, not like a product regression.`,
1704
+ perOutcomeHeading: () => 'What an outcome costs',
1705
+ perOutcomeRow: (key, perCall, perOutcome, coverage) => `${key} ${perCall} ${perOutcome} ${coverage}`,
1706
+ perOutcomeColumns: {
1707
+ workload: 'workload',
1708
+ perCall: 'per call',
1709
+ perOutcome: 'per success',
1710
+ recorded: 'recorded',
1711
+ },
1712
+ perOutcomeWithheld: (why, successes, coverage) =>
1713
+ why === 'too-few-outcomes'
1714
+ ? `${successes} so far`
1715
+ : why === 'too-little-coverage'
1716
+ ? `${coverage} covered`
1717
+ : why === 'no-successes-recorded'
1718
+ ? 'none succeeded'
1719
+ : why === 'nothing-recorded'
1720
+ ? 'not recorded'
1721
+ : 'no vocabulary',
1722
+ perOutcomeNumerator: () =>
1723
+ 'Per success divides the spend on calls that recorded an outcome, never the whole bill \u2014 dividing everything would charge your uninstrumented traffic to your measured successes and report a figure too high by exactly the uncovered share, silently, in the direction that gets a working feature killed. "recorded" is what share of each workload\u2019s spend the figure covers.',
1724
+ perOutcomeDisagreement: (key, callRank, outcomeRank) =>
1725
+ `${key} is #${callRank} by cost per call and #${outcomeRank} by cost per success.`,
1726
+ perOutcomeBothOrders: () =>
1727
+ 'Cheapest per call and cheapest per success are different orders, and both are printed rather than one being picked. A workload can move up one while moving down the other, and somebody optimising on the first number would be moving the wrong one.',
1656
1728
  outcomeColumns: { outcome: 'outcome', calls: 'calls', spend: 'spend' },
1657
1729
  verdictSuccess: () => 'success',
1658
1730
  verdictOther: () => '\u2014',
package/src/i18n/es.ts CHANGED
@@ -45,6 +45,7 @@ ${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 ladder <log>
48
49
  trazum feedback
49
50
  trazum --version
50
51
 
@@ -617,6 +618,12 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
617
618
  spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
618
619
  dólares para "trazum profile". Una etiqueta con presupuesto y sin
619
620
  llamadas se informa como no medida, nunca como aprobada
621
+ ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
622
+ "escalateOn": ["escalated"] } } — modelo barato primero, escalar
623
+ un fallo registrado a uno más caro. Los dos campos obligatorios.
624
+ "trazum ladder <log>" imprime la tasa de escalado de equilibrio
625
+ junto a la medida: un escalado paga dos veces, así que por encima
626
+ de esa tasa la escalera cuesta más que no haberla construido
620
627
  outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
621
628
  tu propio vocabulario para lo que pasó, y cuál de él cuenta como
622
629
  acierto. Los dos son obligatorios: qué palabras significan éxito
@@ -993,6 +1000,47 @@ ${bold('EJEMPLOS')}
993
1000
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
994
1001
  },
995
1002
 
1003
+ ladder: {
1004
+ heading: () => 'Escaleras de escalado',
1005
+ noLadders: () =>
1006
+ '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"]}}.',
1007
+ workload: (label) => label,
1008
+ arithmetic: (cheap, dear, breakEven) =>
1009
+ `${cheap} por llamada barata, ${dear} cara. Tasa de escalado de equilibrio: ${breakEven}.`,
1010
+ measured: (rate, escalations, calls) =>
1011
+ `Medido: ${rate} (${escalations} de ${calls} llamadas escalaron).`,
1012
+ saving: (delta) => `Ahorra ${delta} por llamada frente a no haberla construido.`,
1013
+ costing: (delta) =>
1014
+ `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.`,
1015
+ atBreakEven: (band) =>
1016
+ `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.`,
1017
+ cannotTell: (why, calls) =>
1018
+ why === 'too-few-calls'
1019
+ ? `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.`
1020
+ : why === 'no-outcomes-recorded'
1021
+ ? '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.'
1022
+ : why === 'tier-unpriced'
1023
+ ? '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.'
1024
+ : 'No se puede decir: esta escalera no declara valores de escalado.',
1025
+ problemsHeading: (label) => `${label} — esta escalera no va a hacer lo que parece`,
1026
+ problem: (kind, detail) =>
1027
+ kind === 'escalate-on-a-success'
1028
+ ? `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.`
1029
+ : kind === 'escalate-on-undeclared'
1030
+ ? `escalateOn nombra "${detail}", que "outcomes.values" no declara. Esta escalera no se dispara nunca, en silencio.`
1031
+ : kind === 'tiers-not-cheapest-first'
1032
+ ? `"${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.`
1033
+ : kind === 'duplicate-tier'
1034
+ ? `"${detail}" aparece dos veces en tiers.`
1035
+ : kind === 'unknown-model'
1036
+ ? `"${detail}" no est\u00e1 en la tabla de precios.`
1037
+ : `una escalera necesita al menos dos pelda\u00f1os; esta tiene ${detail}.`,
1038
+ theDoubleSpend: () =>
1039
+ '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.',
1040
+ notExecuted: () =>
1041
+ '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.',
1042
+ },
1043
+
996
1044
  gateway: {
997
1045
  badProvider: (given, known) =>
998
1046
  given === ''
@@ -1686,6 +1734,30 @@ ${bold('EJEMPLOS')}
1686
1734
  `${share} de la factura (${usd}) no llev\u00f3 resultado, y no est\u00e1 en ninguna de las dos mitades de la tasa de arriba.`,
1687
1735
  outcomeUndeclared: (values) =>
1688
1736
  `No declarados en "outcomes.values": ${values}. Nombrados en vez de contados como fallos \u2014 una errata en un exportador debe parecer una errata, no una regresi\u00f3n del producto.`,
1737
+ perOutcomeHeading: () => 'Lo que cuesta un resultado',
1738
+ perOutcomeRow: (key, perCall, perOutcome, coverage) => `${key} ${perCall} ${perOutcome} ${coverage}`,
1739
+ perOutcomeColumns: {
1740
+ workload: 'carga',
1741
+ perCall: 'por llamada',
1742
+ perOutcome: 'por acierto',
1743
+ recorded: 'cubierto',
1744
+ },
1745
+ perOutcomeWithheld: (why, successes, coverage) =>
1746
+ why === 'too-few-outcomes'
1747
+ ? `${successes} hasta ahora`
1748
+ : why === 'too-little-coverage'
1749
+ ? `${coverage} cubierto`
1750
+ : why === 'no-successes-recorded'
1751
+ ? 'ninguno acert\u00f3'
1752
+ : why === 'nothing-recorded'
1753
+ ? 'sin registrar'
1754
+ : 'sin vocabulario',
1755
+ perOutcomeNumerator: () =>
1756
+ 'Por acierto divide el gasto de las llamadas que registraron un resultado, nunca la factura entera \u2014 dividirlo todo cargar\u00eda tu tr\u00e1fico sin instrumentar a tus aciertos medidos y dar\u00eda una cifra alta por exactamente la parte no cubierta, en silencio, en la direcci\u00f3n que mata una funci\u00f3n que s\u00ed funciona. "cubierto" es qu\u00e9 parte del gasto de cada carga cubre la cifra.',
1757
+ perOutcomeDisagreement: (key, callRank, outcomeRank) =>
1758
+ `${key} es el #${callRank} por coste por llamada y el #${outcomeRank} por coste por acierto.`,
1759
+ perOutcomeBothOrders: () =>
1760
+ 'M\u00e1s barato por llamada y m\u00e1s barato por acierto son \u00f3rdenes distintos, y se imprimen los dos en vez de elegir uno. Una carga puede subir en uno mientras baja en el otro, y quien optimice por el primer n\u00famero estar\u00eda moviendo el equivocado.',
1689
1761
  outcomeColumns: { outcome: 'resultado', calls: 'llamadas', spend: 'gasto' },
1690
1762
  verdictSuccess: () => '\u00e9xito',
1691
1763
  verdictOther: () => '\u2014',
package/src/i18n/types.ts CHANGED
@@ -264,6 +264,26 @@ 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
+ ladder: {
272
+ heading(): string;
273
+ noLadders(): string;
274
+ workload(label: string): string;
275
+ arithmetic(cheap: string, dear: string, breakEven: string): string;
276
+ measured(rate: string, escalations: string, calls: string): string;
277
+ saving(delta: string): string;
278
+ costing(delta: string): string;
279
+ atBreakEven(band: string): string;
280
+ cannotTell(why: string, calls: string): string;
281
+ problem(kind: string, detail: string): string;
282
+ problemsHeading(label: string): string;
283
+ theDoubleSpend(): string;
284
+ notExecuted(): string;
285
+ };
286
+
267
287
  gateway: {
268
288
  badProvider(given: string, known: string): string;
269
289
  needsPolicy(policies: string): string;
@@ -1128,6 +1148,13 @@ export interface CliMessages {
1128
1148
  outcomeNoRate(why: string): string;
1129
1149
  outcomeUnrecorded(share: string, usd: string): string;
1130
1150
  outcomeUndeclared(values: string): string;
1151
+ perOutcomeHeading(): string;
1152
+ perOutcomeRow(key: string, perCall: string, perOutcome: string, coverage: string): string;
1153
+ perOutcomeColumns: { workload: string; perCall: string; perOutcome: string; recorded: string };
1154
+ perOutcomeWithheld(why: string, successes: string, coverage: string): string;
1155
+ perOutcomeNumerator(): string;
1156
+ perOutcomeDisagreement(key: string, callRank: string, outcomeRank: string): string;
1157
+ perOutcomeBothOrders(): string;
1131
1158
  outcomeColumns: { outcome: string; calls: string; spend: string };
1132
1159
  verdictSuccess(): string;
1133
1160
  verdictOther(): string;
package/src/index.ts CHANGED
@@ -39,7 +39,11 @@ import {
39
39
  DEFAULT_USAGE,
40
40
  budgetPositions,
41
41
  conform,
42
+ BREAK_EVEN_BAND,
43
+ ladderPosition,
44
+ validateLadder,
42
45
  outcomeReport,
46
+ rankPerOutcome,
43
47
  FAILURE_POLICIES,
44
48
  detectFromSource,
45
49
  matchLocale,
@@ -569,6 +573,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
569
573
  conform: ['contract', 'json'],
570
574
  feedback: [],
571
575
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
576
+ ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
572
577
  where: [],
573
578
  rules: [],
574
579
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2189,6 +2194,136 @@ async function commandGateway(
2189
2194
  console.log();
2190
2195
  }
2191
2196
 
2197
+ /**
2198
+ * `trazum ladder <log>` — is the ladder saving money, or is it a bill?
2199
+ *
2200
+ * The one number this command exists to print is the **break-even escalation
2201
+ * rate**. "We route to the cheap model first" describes a policy that saves
2202
+ * money and a policy that costs money equally well; only the rate separates
2203
+ * them, and nobody works it out in their head because the shape of the
2204
+ * arithmetic is not obvious — an escalation pays twice, since the cheap
2205
+ * attempt is not refunded.
2206
+ */
2207
+ async function commandLadder(
2208
+ args: Args,
2209
+ config: TrazumConfig,
2210
+ pricing: PricingCatalogue,
2211
+ t: CliMessages,
2212
+ ): Promise<void> {
2213
+ const path = args.positional[0];
2214
+ if (path === undefined) {
2215
+ throw new Error(t.errors.missingInputFile());
2216
+ }
2217
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
2218
+ const ladders = config.ladders ?? {};
2219
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2220
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2221
+
2222
+ console.log();
2223
+ console.log(c.bold(t.ladder.heading()));
2224
+ if (Object.keys(ladders).length === 0) {
2225
+ console.log(` ${c.dim(wrap(t.ladder.noLadders(), 74, ' '))}`);
2226
+ console.log();
2227
+ return;
2228
+ }
2229
+ console.log(` ${c.dim(wrap(t.ladder.theDoubleSpend(), 74, ' '))}`);
2230
+ console.log();
2231
+
2232
+ const vocabulary = config.outcomes ?? null;
2233
+ let anyProblem = false;
2234
+
2235
+ for (const [label, policy] of Object.entries(ladders)) {
2236
+ /**
2237
+ * Validated before it is measured, and loudly.
2238
+ *
2239
+ * A ladder that escalates on a value declared a *success* pays twice for
2240
+ * work that already worked, on every call, while looking exactly like a
2241
+ * cost-saving measure in the config. Printing its measured position first
2242
+ * would bury that under a number.
2243
+ */
2244
+ const problems = validateLadder(policy, vocabulary, pricing);
2245
+ if (problems.length > 0) {
2246
+ anyProblem = true;
2247
+ console.log(` ${c.red('✗')} ${c.bold(t.ladder.problemsHeading(label))}`);
2248
+ for (const problem of problems) {
2249
+ const detail =
2250
+ 'value' in problem
2251
+ ? problem.value
2252
+ : 'model' in problem
2253
+ ? problem.model
2254
+ : String(problem.tiers);
2255
+ console.log(` ${wrap(t.ladder.problem(problem.kind, detail), 70, ' ')}`);
2256
+ }
2257
+ console.log();
2258
+ continue;
2259
+ }
2260
+
2261
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
2262
+ const breakdown = report.byLabel.find((entry) => entry.label === label);
2263
+ /**
2264
+ * The shape of the work comes from the measured calls, so the break-even
2265
+ * rate is priced against what this workload actually sends rather than
2266
+ * against a token count somebody guessed at.
2267
+ */
2268
+ const calls = breakdown?.breakdown.calls ?? 0;
2269
+ const shape =
2270
+ breakdown === undefined || calls === 0
2271
+ ? { inputTokens: 0, outputTokens: 0 }
2272
+ : {
2273
+ inputTokens: Math.round(
2274
+ (breakdown.breakdown.inputTokens +
2275
+ breakdown.breakdown.cacheReadTokens +
2276
+ breakdown.breakdown.cacheWriteTokens) /
2277
+ calls,
2278
+ ),
2279
+ outputTokens: Math.round(breakdown.breakdown.outputTokens / calls),
2280
+ };
2281
+
2282
+ const empty = { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 };
2283
+ const position = ladderPosition(policy, slice?.tally ?? empty, shape, vocabulary, pricing);
2284
+
2285
+ console.log(` ${c.bold(t.ladder.workload(label))} ${c.dim(policy.tiers.join(' → '))}`);
2286
+ console.log(
2287
+ ` ${c.dim(
2288
+ t.ladder.arithmetic(
2289
+ formatUsd(position.arithmetic.cheapUsd),
2290
+ formatUsd(position.arithmetic.dearUsd),
2291
+ position.arithmetic.breakEvenRate === null ? '—' : pct(position.arithmetic.breakEvenRate),
2292
+ ),
2293
+ )}`,
2294
+ );
2295
+
2296
+ if (position.verdict === 'cannot-tell') {
2297
+ console.log(
2298
+ ` ${c.yellow('?')} ${wrap(t.ladder.cannotTell(position.unknown ?? '', n(position.calls)), 70, ' ')}`,
2299
+ );
2300
+ } else {
2301
+ console.log(
2302
+ ` ${t.ladder.measured(pct(position.measuredRate ?? 0), n(position.escalations), n(position.calls))}`,
2303
+ );
2304
+ const delta = formatUsd(Math.abs(position.deltaUsdPerCall ?? 0));
2305
+ if (position.verdict === 'saving') {
2306
+ console.log(` ${c.green('✓')} ${wrap(t.ladder.saving(delta), 70, ' ')}`);
2307
+ } else if (position.verdict === 'costing') {
2308
+ console.log(` ${c.red('✗')} ${wrap(t.ladder.costing(delta), 70, ' ')}`);
2309
+ } else {
2310
+ console.log(` ${c.dim('·')} ${wrap(t.ladder.atBreakEven(pct(BREAK_EVEN_BAND)), 70, ' ')}`);
2311
+ }
2312
+ }
2313
+ console.log();
2314
+ }
2315
+
2316
+ console.log(` ${c.dim(wrap(t.ladder.notExecuted(), 74, ' '))}`);
2317
+ console.log();
2318
+
2319
+ /**
2320
+ * A misconfigured ladder fails the command, because it is the one finding
2321
+ * here that is wrong *now* rather than a measurement somebody should look
2322
+ * at. Everything else exits 0: this is a survey, like `doctor`.
2323
+ */
2324
+ if (anyProblem) process.exitCode = 1;
2325
+ }
2326
+
2192
2327
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2193
2328
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2194
2329
  const col = t.models.columns;
@@ -6497,6 +6632,76 @@ async function commandProfile(
6497
6632
  )}`,
6498
6633
  );
6499
6634
  }
6635
+ /**
6636
+ * What an outcome costs, per workload — the finding a total cannot make.
6637
+ *
6638
+ * Printed only when at least one workload has enough recorded outcomes
6639
+ * to say something, and every row that cannot state a figure says which
6640
+ * of the five reasons applies rather than showing a blank.
6641
+ */
6642
+ const ranking = rankPerOutcome(
6643
+ report.outcomeTallyByLabel.map((entry) => ({
6644
+ key: entry.label,
6645
+ calls: entry.calls,
6646
+ totalUsd: entry.totalUsd,
6647
+ tally: entry.tally,
6648
+ })),
6649
+ config.outcomes ?? null,
6650
+ );
6651
+ if (ranking.byCall.length > 0) {
6652
+ console.log();
6653
+ console.log(c.bold(t.profile.perOutcomeHeading()));
6654
+ const pcol = t.profile.perOutcomeColumns;
6655
+ const prows = ranking.byCall.map((slice) => ({
6656
+ key: slice.key,
6657
+ perCall: formatUsd(slice.usdPerCall),
6658
+ perOutcome:
6659
+ slice.per.usdPerSuccess !== null
6660
+ ? formatUsd(slice.per.usdPerSuccess)
6661
+ : t.profile.perOutcomeWithheld(
6662
+ slice.per.withheld ?? 'no-vocabulary',
6663
+ n(slice.per.successes),
6664
+ pct(slice.per.coverage),
6665
+ ),
6666
+ recorded: pct(slice.per.coverage),
6667
+ }));
6668
+ const pw = {
6669
+ key: Math.max(...prows.map((r) => r.key.length), pcol.workload.length),
6670
+ perCall: Math.max(...prows.map((r) => r.perCall.length), pcol.perCall.length),
6671
+ perOutcome: Math.max(...prows.map((r) => r.perOutcome.length), pcol.perOutcome.length),
6672
+ recorded: Math.max(...prows.map((r) => r.recorded.length), pcol.recorded.length),
6673
+ };
6674
+ console.log(
6675
+ c.dim(
6676
+ ` ${pcol.workload.padEnd(pw.key)} ${pcol.perCall.padStart(pw.perCall)} ` +
6677
+ `${pcol.perOutcome.padStart(pw.perOutcome)} ${pcol.recorded.padStart(pw.recorded)}`,
6678
+ ),
6679
+ );
6680
+ for (const row of prows) {
6681
+ console.log(
6682
+ ` ${row.key.padEnd(pw.key)} ${row.perCall.padStart(pw.perCall)} ` +
6683
+ `${row.perOutcome.padStart(pw.perOutcome)} ${c.dim(row.recorded.padStart(pw.recorded))}`,
6684
+ );
6685
+ }
6686
+ console.log();
6687
+ console.log(` ${c.dim(wrap(t.profile.perOutcomeNumerator(), 74, ' '))}`);
6688
+
6689
+ // The disagreement between the two orders, which is itself the finding.
6690
+ if (ranking.disagreements.length > 0) {
6691
+ console.log();
6692
+ console.log(` ${c.dim(wrap(t.profile.perOutcomeBothOrders(), 74, ' '))}`);
6693
+ for (const d of ranking.disagreements) {
6694
+ console.log(
6695
+ ` ${c.yellow('→')} ${wrap(
6696
+ t.profile.perOutcomeDisagreement(d.slice.key, n(d.callRank + 1), n(d.outcomeRank + 1)),
6697
+ 74,
6698
+ ' ',
6699
+ )}`,
6700
+ );
6701
+ }
6702
+ }
6703
+ }
6704
+
6500
6705
  if (outcomes.undeclared.length > 0) {
6501
6706
  console.log(
6502
6707
  ` ${c.yellow('!')} ${wrap(
@@ -8319,6 +8524,9 @@ async function main(): Promise<void> {
8319
8524
  case 'models':
8320
8525
  commandModels(t, pricing);
8321
8526
  break;
8527
+ case 'ladder':
8528
+ await commandLadder(args, config, pricing, t);
8529
+ break;
8322
8530
  case 'gateway':
8323
8531
  await commandGateway(args, config, configDir, pricing, t);
8324
8532
  break;