@trazum/cli 1.50.6 → 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.6",
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.6"
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,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 experiment <log> --a <label> --b <label> --min-outcomes <n>
61
62
  trazum ladder <log>
62
63
  trazum feedback
63
64
  trazum --version
@@ -119,6 +120,18 @@ ${bold('OPTIONS FOR gateway')}
119
120
 
120
121
  Your credential is forwarded untouched and never read. See docs/gateway.md.
121
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
+
122
135
  ${bold('OPTIONS FOR prune')}
123
136
  --cases <file> One input per line, or a JSON array. Required.
124
137
  --yes Actually spend the calls. Without it the estimate is
@@ -964,6 +977,32 @@ ${bold('EXAMPLES')}
964
977
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
965
978
  },
966
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
+
967
1006
  ladder: {
968
1007
  heading: () => 'Escalation ladders',
969
1008
  noLadders: () =>
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 experiment <log> --a <label> --b <label> --min-outcomes <n>
48
49
  trazum ladder <log>
49
50
  trazum feedback
50
51
  trazum --version
@@ -109,6 +110,18 @@ ${bold('OPCIONES DE gateway')}
109
110
 
110
111
  Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
111
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
+
112
125
  ${bold('OPCIONES DE prune')}
113
126
  --cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
114
127
  --yes Gasta las llamadas de verdad. Sin él se imprime la
@@ -1000,6 +1013,32 @@ ${bold('EJEMPLOS')}
1000
1013
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
1001
1014
  },
1002
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
+
1003
1042
  ladder: {
1004
1043
  heading: () => 'Escaleras de escalado',
1005
1044
  noLadders: () =>
package/src/i18n/types.ts CHANGED
@@ -268,6 +268,20 @@ export interface CliMessages {
268
268
  * The ladder. Every line here exists to stop somebody reading "we route to
269
269
  * the cheap model first" as a saving without the number that decides it.
270
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
+
271
285
  ladder: {
272
286
  heading(): string;
273
287
  noLadders(): string;
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  budgetPositions,
41
41
  conform,
42
42
  BREAK_EVEN_BAND,
43
+ runExperiment,
43
44
  ladderPosition,
44
45
  validateLadder,
45
46
  outcomeReport,
@@ -139,6 +140,7 @@ import type {
139
140
  import type {
140
141
  BudgetReport,
141
142
  ContractName,
143
+ ExperimentArm,
142
144
  FailurePolicy,
143
145
  GatewayStanding,
144
146
  UsageProfileReport,
@@ -245,6 +247,9 @@ interface Args {
245
247
  }
246
248
 
247
249
  const VALUE_FLAGS = new Set([
250
+ 'a',
251
+ 'b',
252
+ 'min-outcomes',
248
253
  'against',
249
254
  'contract',
250
255
  'on-cannot-tell',
@@ -574,6 +579,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
574
579
  feedback: [],
575
580
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
576
581
  ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
582
+ experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
577
583
  where: [],
578
584
  rules: [],
579
585
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2324,6 +2330,135 @@ async function commandLadder(
2324
2330
  if (anyProblem) process.exitCode = 1;
2325
2331
  }
2326
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
+
2327
2462
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2328
2463
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2329
2464
  const col = t.models.columns;
@@ -8524,6 +8659,9 @@ async function main(): Promise<void> {
8524
8659
  case 'models':
8525
8660
  commandModels(t, pricing);
8526
8661
  break;
8662
+ case 'experiment':
8663
+ await commandExperiment(args, config, pricing, t);
8664
+ break;
8527
8665
  case 'ladder':
8528
8666
  await commandLadder(args, config, pricing, t);
8529
8667
  break;