@trazum/cli 1.50.7 → 1.50.8

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.7",
3
+ "version": "1.50.8",
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.7"
40
+ "@trazum/core": "1.50.8"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -59,6 +59,7 @@ ${bold('USAGE')}
59
59
  trazum rules
60
60
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
61
61
  trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
62
+ trazum quality <log> --label <name> --at <iso> [--gate]
62
63
  trazum ladder <log>
63
64
  trazum feedback
64
65
  trazum --version
@@ -132,6 +133,20 @@ ${bold('OPTIONS FOR experiment')}
132
133
  not separable — with the number of outcomes per arm that would settle it, so
133
134
  "run it longer" is an instruction rather than a shrug. Nothing is promoted.
134
135
 
136
+ ${bold('OPTIONS FOR quality')}
137
+ --label <name> The workload to judge. Required: a mixture would
138
+ average a regression away.
139
+ --at <iso> When the change landed. Required: without a
140
+ boundary there is nothing to compare across, and
141
+ picking one would be this tool choosing which
142
+ change to blame.
143
+ --gate Exit 1 on a measured drop, 2 on "cannot tell".
144
+ Three outcomes, never two.
145
+
146
+ A before-and-after rather than an experiment, so it reports "cannot tell"
147
+ whenever the model mix, the call volume or the outcome coverage moved across
148
+ the boundary — the prompt is not the only variable and it says so.
149
+
135
150
  ${bold('OPTIONS FOR prune')}
136
151
  --cases <file> One input per line, or a JSON array. Required.
137
152
  --yes Actually spend the calls. Without it the estimate is
@@ -977,6 +992,43 @@ ${bold('EXAMPLES')}
977
992
  `${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
978
993
  },
979
994
 
995
+ quality: {
996
+ heading: (label) => `Quality across the change: ${label}`,
997
+ needsLabel: () => 'Name the workload with --label: this compares one label before and after a change, and a mixture of workloads would average a regression away.',
998
+ needsAt: () =>
999
+ '--at is required: give the moment the change landed, as an ISO timestamp. Without it there is no boundary to compare across, and picking one from the log would be this tool choosing which change to blame.',
1000
+ sides: (beforeRate, afterRate, before, after) =>
1001
+ `before ${beforeRate} (${before} outcomes) after ${afterRate} (${after} outcomes)`,
1002
+ dropped: (from, to, outcomes, cost) =>
1003
+ `The resolution rate moved from ${from} to ${to} on ${outcomes} measured outcomes, and this change ${cost}. Both halves are measured; neither is an estimate.`,
1004
+ held: (from, to, outcomes) =>
1005
+ `The resolution rate moved from ${from} to ${to} on ${outcomes} measured outcomes \u2014 up, measurably.`,
1006
+ cannotTell: (why, need) =>
1007
+ why === 'too-few-before'
1008
+ ? `Cannot tell: only ${need} outcomes before the change, and this gate needs 100 a side. It fails builds, so the threshold is not the one a rate uses elsewhere.`
1009
+ : why === 'too-few-after'
1010
+ ? `Cannot tell yet: only ${need} outcomes since the change, and this gate needs 100 a side. Run it again once the traffic has caught up.`
1011
+ : why === 'not-separable'
1012
+ ? 'Cannot tell: the rate did not measurably move. That is NOT the same as "it held" \u2014 a gate that spelled them the same way would pass a real regression it merely lacked the power to see.'
1013
+ : why === 'no-vocabulary'
1014
+ ? 'Cannot tell: "outcomes.success" declares nothing, so there is no resolution rate to compare.'
1015
+ : 'Cannot tell: something other than the prompt moved across the boundary. See below.',
1016
+ confoundersHeading: () => 'The prompt is not the only thing that changed',
1017
+ confounder: (kind, detail) =>
1018
+ kind === 'model-mix-moved'
1019
+ ? `The model mix moved by ${detail}. The drop may be entirely somebody else's migration, and this tool cannot separate the two.`
1020
+ : kind === 'volume-moved'
1021
+ ? `The call volume moved ${detail}. A workload whose traffic moved that much is usually a workload whose population changed \u2014 a new surface, a new customer, a campaign \u2014 and the questions being asked are not the questions from before.`
1022
+ : `Outcome coverage moved from ${detail}. The two rates describe different populations: a team that starts instrumenting its hard cases sees its measured rate fall without anything having got worse.`,
1023
+ notRandomised: () =>
1024
+ 'This is a before-and-after, not an experiment. It splits traffic by time rather than at random, so everything else that changed at the same time is in the difference too \u2014 which is why it says "cannot tell" far more readily than an A/B would.',
1025
+ cannotSee: () =>
1026
+ 'It cannot see anything else you deployed that day. A "dropped" verdict says the rate fell and the three things it can check did not move. That is a smaller claim than "the prompt did it", and it is the largest one the evidence supports.',
1027
+ gateFailed: () => 'Gate failed: a measured drop with nothing else to explain it.',
1028
+ gateHeldOpen: () =>
1029
+ 'Gate not passed and not failed. "Cannot tell" holds the claim open rather than exiting green \u2014 the posture verify has had since 1.39.',
1030
+ },
1031
+
980
1032
  experiment: {
981
1033
  heading: (a, b) => `Experiment: ${a} against ${b}`,
982
1034
  needsTwo: () =>
package/src/i18n/es.ts CHANGED
@@ -46,6 +46,7 @@ ${bold('USO')}
46
46
  trazum rules
47
47
  trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
48
48
  trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
49
+ trazum quality <log> --label <name> --at <iso> [--gate]
49
50
  trazum ladder <log>
50
51
  trazum feedback
51
52
  trazum --version
@@ -122,6 +123,20 @@ ${bold('OPCIONES DE experiment')}
122
123
  o no separables — con cuántos resultados por brazo lo zanjarían, para que
123
124
  "déjalo correr más" sea una instrucción y no un encogimiento de hombros.
124
125
 
126
+ ${bold('OPCIONES DE quality')}
127
+ --label <nombre> La carga a juzgar. Obligatorio: una mezcla
128
+ promediaría una regresión hasta hacerla
129
+ desaparecer.
130
+ --at <iso> Cuándo aterrizó el cambio. Obligatorio: sin
131
+ frontera no hay nada que comparar, y elegir una
132
+ sería que esta herramienta decida a qué culpar.
133
+ --gate Sale con 1 si hay caída medida, 2 si no se puede
134
+ decir. Tres resultados, nunca dos.
135
+
136
+ Un antes y después, no un experimento, así que dice "no se puede decir"
137
+ siempre que la mezcla de modelos, el volumen o la cobertura se movieran a
138
+ través de la frontera — el prompt no es la única variable y lo dice.
139
+
125
140
  ${bold('OPCIONES DE prune')}
126
141
  --cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
127
142
  --yes Gasta las llamadas de verdad. Sin él se imprime la
@@ -1013,6 +1028,43 @@ ${bold('EJEMPLOS')}
1013
1028
  `${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
1014
1029
  },
1015
1030
 
1031
+ quality: {
1032
+ heading: (label) => `Calidad a trav\u00e9s del cambio: ${label}`,
1033
+ needsLabel: () => 'Nombra la carga con --label: esto compara una etiqueta antes y despu\u00e9s de un cambio, y una mezcla de cargas promediar\u00eda una regresi\u00f3n hasta hacerla desaparecer.',
1034
+ needsAt: () =>
1035
+ '--at es obligatorio: da el momento en que aterriz\u00f3 el cambio, como marca ISO. Sin \u00e9l no hay frontera que comparar, y elegir una del log ser\u00eda que esta herramienta decida a qu\u00e9 cambio culpar.',
1036
+ sides: (beforeRate, afterRate, before, after) =>
1037
+ `antes ${beforeRate} (${before} resultados) despu\u00e9s ${afterRate} (${after} resultados)`,
1038
+ dropped: (from, to, outcomes, cost) =>
1039
+ `La tasa de resoluci\u00f3n pas\u00f3 de ${from} a ${to} sobre ${outcomes} resultados medidos, y este cambio ${cost}. Las dos mitades son medidas; ninguna es una estimaci\u00f3n.`,
1040
+ held: (from, to, outcomes) =>
1041
+ `La tasa de resoluci\u00f3n pas\u00f3 de ${from} a ${to} sobre ${outcomes} resultados medidos \u2014 arriba, de forma medible.`,
1042
+ cannotTell: (why, need) =>
1043
+ why === 'too-few-before'
1044
+ ? `No se puede decir: solo ${need} resultados antes del cambio, y esta puerta necesita 100 por lado. Falla builds, as\u00ed que el umbral no es el que usa una tasa en otros sitios.`
1045
+ : why === 'too-few-after'
1046
+ ? `A\u00fan no se puede decir: solo ${need} resultados desde el cambio, y esta puerta necesita 100 por lado. Vuelve a correrlo cuando el tr\u00e1fico se ponga al d\u00eda.`
1047
+ : why === 'not-separable'
1048
+ ? 'No se puede decir: la tasa no se movi\u00f3 de forma medible. Eso NO es lo mismo que "se mantuvo" \u2014 una puerta que las escribiera igual dejar\u00eda pasar una regresi\u00f3n real que simplemente no tuvo potencia para ver.'
1049
+ : why === 'no-vocabulary'
1050
+ ? 'No se puede decir: "outcomes.success" no declara nada, as\u00ed que no hay tasa de resoluci\u00f3n que comparar.'
1051
+ : 'No se puede decir: algo que no es el prompt se movi\u00f3 a trav\u00e9s de la frontera. Ver abajo.',
1052
+ confoundersHeading: () => 'El prompt no es lo \u00fanico que cambi\u00f3',
1053
+ confounder: (kind, detail) =>
1054
+ kind === 'model-mix-moved'
1055
+ ? `La mezcla de modelos se movi\u00f3 un ${detail}. La ca\u00edda puede ser enteramente la migraci\u00f3n de otra persona, y esta herramienta no puede separarlas.`
1056
+ : kind === 'volume-moved'
1057
+ ? `El volumen de llamadas se movi\u00f3 ${detail}. Una carga cuyo tr\u00e1fico se mueve tanto suele ser una carga cuya poblaci\u00f3n cambi\u00f3 \u2014 una superficie nueva, un cliente nuevo, una campa\u00f1a \u2014 y las preguntas que se hacen no son las de antes.`
1058
+ : `La cobertura de resultados pas\u00f3 de ${detail}. Las dos tasas describen poblaciones distintas: un equipo que empieza a instrumentar sus casos dif\u00edciles ve caer su tasa medida sin que nada haya empeorado.`,
1059
+ notRandomised: () =>
1060
+ 'Esto es un antes y despu\u00e9s, no un experimento. Parte el tr\u00e1fico por tiempo y no al azar, as\u00ed que todo lo dem\u00e1s que cambi\u00f3 a la vez est\u00e1 tambi\u00e9n en la diferencia \u2014 por eso dice "no se puede decir" mucho m\u00e1s f\u00e1cilmente que un A/B.',
1061
+ cannotSee: () =>
1062
+ 'No puede ver nada m\u00e1s que desplegaras ese d\u00eda. Un veredicto de "ca\u00edda" dice que la tasa baj\u00f3 y que las tres cosas que puede comprobar no se movieron. Es una afirmaci\u00f3n m\u00e1s peque\u00f1a que "lo hizo el prompt", y es la mayor que sostiene la evidencia.',
1063
+ gateFailed: () => 'Puerta fallada: una ca\u00edda medida sin nada m\u00e1s que la explique.',
1064
+ gateHeldOpen: () =>
1065
+ 'Puerta ni pasada ni fallada. "No se puede decir" mantiene la afirmaci\u00f3n abierta en vez de salir en verde \u2014 la postura de verify desde 1.39.',
1066
+ },
1067
+
1016
1068
  experiment: {
1017
1069
  heading: (a, b) => `Experimento: ${a} contra ${b}`,
1018
1070
  needsTwo: () =>
package/src/i18n/types.ts CHANGED
@@ -268,6 +268,22 @@ 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
+ quality: {
272
+ heading(label: string): string;
273
+ needsLabel(): string;
274
+ needsAt(): string;
275
+ sides(beforeRate: string, afterRate: string, before: string, after: string): string;
276
+ dropped(from: string, to: string, outcomes: string, cost: string): string;
277
+ held(from: string, to: string, outcomes: string): string;
278
+ cannotTell(why: string, need: string): string;
279
+ confounder(kind: string, detail: string): string;
280
+ confoundersHeading(): string;
281
+ notRandomised(): string;
282
+ cannotSee(): string;
283
+ gateFailed(): string;
284
+ gateHeldOpen(): string;
285
+ };
286
+
271
287
  experiment: {
272
288
  heading(a: string, b: string): string;
273
289
  needsTwo(): string;
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  conform,
42
42
  BREAK_EVEN_BAND,
43
43
  runExperiment,
44
+ qualityGate,
44
45
  ladderPosition,
45
46
  validateLadder,
46
47
  outcomeReport,
@@ -141,6 +142,7 @@ import type {
141
142
  BudgetReport,
142
143
  ContractName,
143
144
  ExperimentArm,
145
+ GateSide,
144
146
  FailurePolicy,
145
147
  GatewayStanding,
146
148
  UsageProfileReport,
@@ -248,6 +250,7 @@ interface Args {
248
250
 
249
251
  const VALUE_FLAGS = new Set([
250
252
  'a',
253
+ 'at',
251
254
  'b',
252
255
  'min-outcomes',
253
256
  'against',
@@ -580,6 +583,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
580
583
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
581
584
  ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
582
585
  experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
586
+ quality: ['label', 'at', 'gate', 'pricing', 'pricing-live'],
583
587
  where: [],
584
588
  rules: [],
585
589
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -2459,6 +2463,152 @@ async function commandExperiment(
2459
2463
  console.log();
2460
2464
  }
2461
2465
 
2466
+ /**
2467
+ * `trazum quality <log> --label <name> --at <iso> [--gate]`
2468
+ *
2469
+ * The failure that actually matters: a prompt edit that quietly made the
2470
+ * product worse. CI has been able to fail a build for tokens since 1.4 and for
2471
+ * dollars since 1.21, and this has never been gateable — so every saving this
2472
+ * tool has ever recommended went into a repository with its most important
2473
+ * consequence unmeasured.
2474
+ *
2475
+ * **Named `quality` rather than `check --against-outcomes`, which is what the
2476
+ * plan called for.** `check` reads *prompt files* and gates on tokens; it has
2477
+ * never opened a usage log, and a command that takes either a prompt or a log
2478
+ * depending on a flag is two commands wearing one name. The split-by-time this
2479
+ * needs is also not a `check` idea — there is nothing in a prompt file with a
2480
+ * timestamp on it.
2481
+ */
2482
+ async function commandQuality(
2483
+ args: Args,
2484
+ config: TrazumConfig,
2485
+ pricing: PricingCatalogue,
2486
+ t: CliMessages,
2487
+ ): Promise<void> {
2488
+ const path = args.positional[0];
2489
+ if (path === undefined) throw new Error(t.errors.missingInputFile());
2490
+
2491
+ const label = stringFlag(args, 'label');
2492
+ if (label === undefined) throw new Error(t.quality.needsLabel());
2493
+
2494
+ const atRaw = stringFlag(args, 'at');
2495
+ const atMs = atRaw === undefined ? Number.NaN : Date.parse(atRaw);
2496
+ if (!Number.isFinite(atMs)) throw new Error(t.quality.needsAt());
2497
+
2498
+ /**
2499
+ * Two profiles over the same file, split at the boundary — rather than one
2500
+ * profile the caller has to slice.
2501
+ *
2502
+ * The alternative is asking somebody for two logs, which invites the mistake
2503
+ * this whole module exists to avoid: two files gathered under conditions
2504
+ * nobody wrote down.
2505
+ */
2506
+ const raw = await readUsageLog(path, t);
2507
+ const sideOf = (since: number | undefined, until: number | undefined): GateSide => {
2508
+ const report = profileUsage(raw, { catalogue: pricing, label, sinceMs: since, untilMs: until });
2509
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
2510
+ return {
2511
+ arm: {
2512
+ name: label,
2513
+ totalUsd: report.total.totalUsd,
2514
+ tally: slice?.tally ?? { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 },
2515
+ },
2516
+ calls: report.total.calls,
2517
+ usdByModel: report.byModel.map((entry) => ({ model: entry.model, usd: entry.breakdown.totalUsd })),
2518
+ };
2519
+ };
2520
+
2521
+ const result = qualityGate(sideOf(undefined, atMs), sideOf(atMs, undefined), config.outcomes ?? null);
2522
+ const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
2523
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2524
+
2525
+ console.log();
2526
+ console.log(c.bold(t.quality.heading(label)));
2527
+ console.log(` ${c.dim(wrap(t.quality.notRandomised(), 74, ' '))}`);
2528
+ console.log();
2529
+ console.log(
2530
+ ` ${t.quality.sides(
2531
+ result.before.rate === null ? '—' : pct(result.before.rate),
2532
+ result.after.rate === null ? '—' : pct(result.after.rate),
2533
+ n(result.outcomes.before),
2534
+ n(result.outcomes.after),
2535
+ )}`,
2536
+ );
2537
+ console.log();
2538
+
2539
+ if (result.verdict === 'dropped') {
2540
+ const cost =
2541
+ result.cost === null
2542
+ ? ''
2543
+ : result.cost.deltaUsdPerCall < 0
2544
+ ? `saves ${formatUsd(-result.cost.deltaUsdPerCall)} a call`
2545
+ : `costs ${formatUsd(result.cost.deltaUsdPerCall)} a call more`;
2546
+ console.log(
2547
+ ` ${c.red('✗')} ${wrap(
2548
+ t.quality.dropped(
2549
+ pct(result.before.rate ?? 0),
2550
+ pct(result.after.rate ?? 0),
2551
+ n(result.outcomes.before + result.outcomes.after),
2552
+ cost,
2553
+ ),
2554
+ 74,
2555
+ ' ',
2556
+ )}`,
2557
+ );
2558
+ } else if (result.verdict === 'held') {
2559
+ console.log(
2560
+ ` ${c.green('✓')} ${wrap(
2561
+ t.quality.held(pct(result.before.rate ?? 0), pct(result.after.rate ?? 0), n(result.outcomes.before + result.outcomes.after)),
2562
+ 74,
2563
+ ' ',
2564
+ )}`,
2565
+ );
2566
+ } else {
2567
+ const need =
2568
+ result.unknown === 'too-few-before' ? n(result.outcomes.before) : n(result.outcomes.after);
2569
+ console.log(` ${c.yellow('?')} ${wrap(t.quality.cannotTell(result.unknown ?? '', need), 74, ' ')}`);
2570
+ }
2571
+
2572
+ /**
2573
+ * Confounders print on **every** verdict, not only on `cannot-tell`.
2574
+ *
2575
+ * A rate that held while the model changed underneath is not evidence that
2576
+ * the prompt is fine either, and hiding the confounder on a green result is
2577
+ * how a gate teaches people to trust it in exactly the case it should not be
2578
+ * trusted.
2579
+ */
2580
+ if (result.confounders.length > 0) {
2581
+ console.log();
2582
+ console.log(` ${c.bold(t.quality.confoundersHeading())}`);
2583
+ for (const confounder of result.confounders) {
2584
+ const detail =
2585
+ confounder.kind === 'model-mix-moved'
2586
+ ? `${pct(confounder.drift)} (${confounder.model})`
2587
+ : confounder.kind === 'volume-moved'
2588
+ ? `${n(confounder.beforeCalls)} → ${n(confounder.afterCalls)} calls`
2589
+ : `${pct(confounder.before)} → ${pct(confounder.after)}`;
2590
+ console.log(` ${c.yellow('!')} ${wrap(t.quality.confounder(confounder.kind, detail), 70, ' ')}`);
2591
+ }
2592
+ }
2593
+
2594
+ console.log();
2595
+ console.log(` ${c.dim(wrap(t.quality.cannotSee(), 74, ' '))}`);
2596
+
2597
+ if (boolFlag(args, 'gate')) {
2598
+ console.log();
2599
+ if (result.verdict === 'dropped') {
2600
+ console.log(` ${c.red(t.quality.gateFailed())}`);
2601
+ process.exitCode = 1;
2602
+ } else if (result.verdict === 'cannot-tell') {
2603
+ // Three outcomes, never two. `cannot tell` holds the claim open rather
2604
+ // than exiting green, the posture `verify --gate` has had since 1.39.
2605
+ console.log(` ${c.yellow(t.quality.gateHeldOpen())}`);
2606
+ process.exitCode = 2;
2607
+ }
2608
+ }
2609
+ console.log();
2610
+ }
2611
+
2462
2612
  function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
2463
2613
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2464
2614
  const col = t.models.columns;
@@ -8659,6 +8809,9 @@ async function main(): Promise<void> {
8659
8809
  case 'models':
8660
8810
  commandModels(t, pricing);
8661
8811
  break;
8812
+ case 'quality':
8813
+ await commandQuality(args, config, pricing, t);
8814
+ break;
8662
8815
  case 'experiment':
8663
8816
  await commandExperiment(args, config, pricing, t);
8664
8817
  break;