@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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +42 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +42 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +15 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +116 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +52 -0
- package/src/i18n/es.ts +52 -0
- package/src/i18n/types.ts +16 -0
- package/src/index.ts +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.50.
|
|
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.
|
|
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;
|