@trazum/cli 1.50.6 → 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 +72 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +72 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +28 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +204 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +91 -0
- package/src/i18n/es.ts +91 -0
- package/src/i18n/types.ts +30 -0
- package/src/index.ts +291 -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
|
@@ -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 quality <log> --label <name> --at <iso> [--gate]
|
|
61
63
|
trazum ladder <log>
|
|
62
64
|
trazum feedback
|
|
63
65
|
trazum --version
|
|
@@ -119,6 +121,32 @@ ${bold('OPTIONS FOR gateway')}
|
|
|
119
121
|
|
|
120
122
|
Your credential is forwarded untouched and never read. See docs/gateway.md.
|
|
121
123
|
|
|
124
|
+
${bold('OPTIONS FOR experiment')}
|
|
125
|
+
--a <label>, --b <label> The two workloads to compare.
|
|
126
|
+
--min-outcomes <n> Required: how many outcomes each arm must record
|
|
127
|
+
before the result may be read. A stopping rule
|
|
128
|
+
declared after looking at the numbers is not a
|
|
129
|
+
stopping rule, and the report says whether it was
|
|
130
|
+
honoured.
|
|
131
|
+
|
|
132
|
+
Judges recorded outcomes and cost together. Three-valued: A wins, B wins, or
|
|
133
|
+
not separable — with the number of outcomes per arm that would settle it, so
|
|
134
|
+
"run it longer" is an instruction rather than a shrug. Nothing is promoted.
|
|
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
|
+
|
|
122
150
|
${bold('OPTIONS FOR prune')}
|
|
123
151
|
--cases <file> One input per line, or a JSON array. Required.
|
|
124
152
|
--yes Actually spend the calls. Without it the estimate is
|
|
@@ -964,6 +992,69 @@ ${bold('EXAMPLES')}
|
|
|
964
992
|
`${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
|
|
965
993
|
},
|
|
966
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
|
+
|
|
1032
|
+
experiment: {
|
|
1033
|
+
heading: (a, b) => `Experiment: ${a} against ${b}`,
|
|
1034
|
+
needsTwo: () =>
|
|
1035
|
+
'Name two labels to compare, for example: trazum experiment <log> --a prompt-v1 --b prompt-v2',
|
|
1036
|
+
needsRule: () =>
|
|
1037
|
+
'--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.',
|
|
1038
|
+
arm: (name, rate, successes, recorded, interval) =>
|
|
1039
|
+
`${name} ${rate} (${successes} of ${recorded} recorded) 95% ${interval}`,
|
|
1040
|
+
wins: (name, low, high) =>
|
|
1041
|
+
`${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.`,
|
|
1042
|
+
notSeparable: (why, needed) =>
|
|
1043
|
+
why === 'no-difference-observed'
|
|
1044
|
+
? '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.'
|
|
1045
|
+
: why === 'nothing-recorded'
|
|
1046
|
+
? 'Not separable: an arm recorded no outcomes at all, so there is no rate to compare.'
|
|
1047
|
+
: `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.`,
|
|
1048
|
+
peeked: (short, declared, recorded) =>
|
|
1049
|
+
`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.`,
|
|
1050
|
+
honoured: (declared) => `Stopping rule honoured: both arms cleared ${declared} recorded outcomes.`,
|
|
1051
|
+
marginalDearer: (better, usd) =>
|
|
1052
|
+
`${better} resolves more and costs more. One extra success costs ${usd} \u2014 that figure, not the rate, is what the decision turns on.`,
|
|
1053
|
+
marginalCheaper: (better) => `${better} resolves more AND costs less per call. Nothing is being traded.`,
|
|
1054
|
+
neverPromotes: () =>
|
|
1055
|
+
'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.',
|
|
1056
|
+
},
|
|
1057
|
+
|
|
967
1058
|
ladder: {
|
|
968
1059
|
heading: () => 'Escalation ladders',
|
|
969
1060
|
noLadders: () =>
|
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 quality <log> --label <name> --at <iso> [--gate]
|
|
48
50
|
trazum ladder <log>
|
|
49
51
|
trazum feedback
|
|
50
52
|
trazum --version
|
|
@@ -109,6 +111,32 @@ ${bold('OPCIONES DE gateway')}
|
|
|
109
111
|
|
|
110
112
|
Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
|
|
111
113
|
|
|
114
|
+
${bold('OPCIONES DE experiment')}
|
|
115
|
+
--a <etiqueta>, --b <etiqueta> Las dos cargas a comparar.
|
|
116
|
+
--min-outcomes <n> Obligatorio: cuántos resultados debe registrar
|
|
117
|
+
cada brazo antes de poder leer el resultado. Una
|
|
118
|
+
regla de parada declarada después de mirar los
|
|
119
|
+
números no es una regla de parada, y el informe
|
|
120
|
+
dice si se respetó.
|
|
121
|
+
|
|
122
|
+
Juzga resultados registrados y coste a la vez. Tres valores: gana A, gana B,
|
|
123
|
+
o no separables — con cuántos resultados por brazo lo zanjarían, para que
|
|
124
|
+
"déjalo correr más" sea una instrucción y no un encogimiento de hombros.
|
|
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
|
+
|
|
112
140
|
${bold('OPCIONES DE prune')}
|
|
113
141
|
--cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
|
|
114
142
|
--yes Gasta las llamadas de verdad. Sin él se imprime la
|
|
@@ -1000,6 +1028,69 @@ ${bold('EJEMPLOS')}
|
|
|
1000
1028
|
`${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
|
|
1001
1029
|
},
|
|
1002
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
|
+
|
|
1068
|
+
experiment: {
|
|
1069
|
+
heading: (a, b) => `Experimento: ${a} contra ${b}`,
|
|
1070
|
+
needsTwo: () =>
|
|
1071
|
+
'Nombra dos etiquetas a comparar, por ejemplo: trazum experiment <log> --a prompt-v1 --b prompt-v2',
|
|
1072
|
+
needsRule: () =>
|
|
1073
|
+
'--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.',
|
|
1074
|
+
arm: (name, rate, successes, recorded, interval) =>
|
|
1075
|
+
`${name} ${rate} (${successes} de ${recorded} registrados) 95% ${interval}`,
|
|
1076
|
+
wins: (name, low, high) =>
|
|
1077
|
+
`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.`,
|
|
1078
|
+
notSeparable: (why, needed) =>
|
|
1079
|
+
why === 'no-difference-observed'
|
|
1080
|
+
? '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.'
|
|
1081
|
+
: why === 'nothing-recorded'
|
|
1082
|
+
? 'No separables: un brazo no registr\u00f3 ning\u00fan resultado, as\u00ed que no hay tasa que comparar.'
|
|
1083
|
+
: `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.`,
|
|
1084
|
+
peeked: (short, declared, recorded) =>
|
|
1085
|
+
`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.`,
|
|
1086
|
+
honoured: (declared) => `Regla de parada respetada: los dos brazos superaron ${declared} resultados registrados.`,
|
|
1087
|
+
marginalDearer: (better, usd) =>
|
|
1088
|
+
`${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.`,
|
|
1089
|
+
marginalCheaper: (better) => `${better} resuelve m\u00e1s Y cuesta menos por llamada. No se est\u00e1 cambiando nada por nada.`,
|
|
1090
|
+
neverPromotes: () =>
|
|
1091
|
+
'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.',
|
|
1092
|
+
},
|
|
1093
|
+
|
|
1003
1094
|
ladder: {
|
|
1004
1095
|
heading: () => 'Escaleras de escalado',
|
|
1005
1096
|
noLadders: () =>
|
package/src/i18n/types.ts
CHANGED
|
@@ -268,6 +268,36 @@ 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
|
+
|
|
287
|
+
experiment: {
|
|
288
|
+
heading(a: string, b: string): string;
|
|
289
|
+
needsTwo(): string;
|
|
290
|
+
needsRule(): string;
|
|
291
|
+
arm(name: string, rate: string, successes: string, recorded: string, interval: string): string;
|
|
292
|
+
wins(name: string, low: string, high: string): string;
|
|
293
|
+
notSeparable(why: string, needed: string): string;
|
|
294
|
+
peeked(short: string, declared: string, recorded: string): string;
|
|
295
|
+
honoured(declared: string): string;
|
|
296
|
+
marginalDearer(better: string, usd: string): string;
|
|
297
|
+
marginalCheaper(better: string): string;
|
|
298
|
+
neverPromotes(): string;
|
|
299
|
+
};
|
|
300
|
+
|
|
271
301
|
ladder: {
|
|
272
302
|
heading(): string;
|
|
273
303
|
noLadders(): string;
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
budgetPositions,
|
|
41
41
|
conform,
|
|
42
42
|
BREAK_EVEN_BAND,
|
|
43
|
+
runExperiment,
|
|
44
|
+
qualityGate,
|
|
43
45
|
ladderPosition,
|
|
44
46
|
validateLadder,
|
|
45
47
|
outcomeReport,
|
|
@@ -139,6 +141,8 @@ import type {
|
|
|
139
141
|
import type {
|
|
140
142
|
BudgetReport,
|
|
141
143
|
ContractName,
|
|
144
|
+
ExperimentArm,
|
|
145
|
+
GateSide,
|
|
142
146
|
FailurePolicy,
|
|
143
147
|
GatewayStanding,
|
|
144
148
|
UsageProfileReport,
|
|
@@ -245,6 +249,10 @@ interface Args {
|
|
|
245
249
|
}
|
|
246
250
|
|
|
247
251
|
const VALUE_FLAGS = new Set([
|
|
252
|
+
'a',
|
|
253
|
+
'at',
|
|
254
|
+
'b',
|
|
255
|
+
'min-outcomes',
|
|
248
256
|
'against',
|
|
249
257
|
'contract',
|
|
250
258
|
'on-cannot-tell',
|
|
@@ -574,6 +582,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
574
582
|
feedback: [],
|
|
575
583
|
gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
|
|
576
584
|
ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
|
|
585
|
+
experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
|
|
586
|
+
quality: ['label', 'at', 'gate', 'pricing', 'pricing-live'],
|
|
577
587
|
where: [],
|
|
578
588
|
rules: [],
|
|
579
589
|
blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
|
|
@@ -2324,6 +2334,281 @@ async function commandLadder(
|
|
|
2324
2334
|
if (anyProblem) process.exitCode = 1;
|
|
2325
2335
|
}
|
|
2326
2336
|
|
|
2337
|
+
/**
|
|
2338
|
+
* `trazum experiment <log> --a <label> --b <label> --min-outcomes <n>`
|
|
2339
|
+
*
|
|
2340
|
+
* Two arms on real traffic, judged on recorded outcomes and cost together.
|
|
2341
|
+
*
|
|
2342
|
+
* `--min-outcomes` is required and that is the entire point of it. A stopping
|
|
2343
|
+
* rule declared after looking at the numbers is not a stopping rule, and
|
|
2344
|
+
* nothing here can stop somebody reading a result early — what it can do is
|
|
2345
|
+
* make the early read **visible to whoever reads the result later**, which is
|
|
2346
|
+
* the part that survives the afternoon.
|
|
2347
|
+
*/
|
|
2348
|
+
async function commandExperiment(
|
|
2349
|
+
args: Args,
|
|
2350
|
+
config: TrazumConfig,
|
|
2351
|
+
pricing: PricingCatalogue,
|
|
2352
|
+
t: CliMessages,
|
|
2353
|
+
): Promise<void> {
|
|
2354
|
+
const path = args.positional[0];
|
|
2355
|
+
if (path === undefined) throw new Error(t.errors.missingInputFile());
|
|
2356
|
+
|
|
2357
|
+
const aName = stringFlag(args, 'a');
|
|
2358
|
+
const bName = stringFlag(args, 'b');
|
|
2359
|
+
if (aName === undefined || bName === undefined) throw new Error(t.experiment.needsTwo());
|
|
2360
|
+
|
|
2361
|
+
const minRaw = stringFlag(args, 'min-outcomes');
|
|
2362
|
+
const minOutcomesPerArm = minRaw === undefined ? Number.NaN : Number(minRaw);
|
|
2363
|
+
if (!Number.isInteger(minOutcomesPerArm) || minOutcomesPerArm < 1) {
|
|
2364
|
+
throw new Error(t.experiment.needsRule());
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
|
|
2368
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2369
|
+
const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
|
|
2370
|
+
|
|
2371
|
+
const armOf = (label: string): ExperimentArm => {
|
|
2372
|
+
const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
|
|
2373
|
+
return {
|
|
2374
|
+
name: label,
|
|
2375
|
+
totalUsd: slice?.totalUsd ?? 0,
|
|
2376
|
+
tally: slice?.tally ?? { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 },
|
|
2377
|
+
};
|
|
2378
|
+
};
|
|
2379
|
+
|
|
2380
|
+
const result = runExperiment(
|
|
2381
|
+
{ arms: [aName, bName], minOutcomesPerArm },
|
|
2382
|
+
{ a: armOf(aName), b: armOf(bName) },
|
|
2383
|
+
config.outcomes ?? null,
|
|
2384
|
+
);
|
|
2385
|
+
|
|
2386
|
+
console.log();
|
|
2387
|
+
console.log(c.bold(t.experiment.heading(aName, bName)));
|
|
2388
|
+
console.log();
|
|
2389
|
+
for (const side of [result.a, result.b]) {
|
|
2390
|
+
console.log(
|
|
2391
|
+
` ${t.experiment.arm(
|
|
2392
|
+
side.name,
|
|
2393
|
+
side.rate === null ? '—' : pct(side.rate),
|
|
2394
|
+
n(side.successes),
|
|
2395
|
+
n(side.recorded),
|
|
2396
|
+
side.interval === null ? '—' : `[${pct(side.interval.low)}, ${pct(side.interval.high)}]`,
|
|
2397
|
+
)}`,
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
console.log();
|
|
2401
|
+
|
|
2402
|
+
if (result.separation === 'not-separable') {
|
|
2403
|
+
console.log(
|
|
2404
|
+
` ${c.dim('·')} ${wrap(
|
|
2405
|
+
t.experiment.notSeparable(
|
|
2406
|
+
result.notSeparable ?? '',
|
|
2407
|
+
result.outcomesNeededPerArm === null ? '—' : n(result.outcomesNeededPerArm),
|
|
2408
|
+
),
|
|
2409
|
+
74,
|
|
2410
|
+
' ',
|
|
2411
|
+
)}`,
|
|
2412
|
+
);
|
|
2413
|
+
} else {
|
|
2414
|
+
const winner = result.separation === 'a-wins' ? result.a.name : result.b.name;
|
|
2415
|
+
const d = result.difference as { low: number; high: number };
|
|
2416
|
+
// Reported as a magnitude: the sign is carried by which arm is named, and
|
|
2417
|
+
// printing "-30.0% to -18.0%" beside "b wins" is two ways of saying the
|
|
2418
|
+
// same thing that a reader has to reconcile.
|
|
2419
|
+
const lo = Math.min(Math.abs(d.low), Math.abs(d.high));
|
|
2420
|
+
const hi = Math.max(Math.abs(d.low), Math.abs(d.high));
|
|
2421
|
+
console.log(` ${c.green('✓')} ${wrap(t.experiment.wins(winner, pct(lo), pct(hi)), 74, ' ')}`);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* The peek line, printed **whether or not** the arms separated.
|
|
2426
|
+
*
|
|
2427
|
+
* A separable result read too early is still separable and still read too
|
|
2428
|
+
* early. Collapsing the two would hide one of the facts, and it is always
|
|
2429
|
+
* the inconvenient one that goes.
|
|
2430
|
+
*/
|
|
2431
|
+
console.log();
|
|
2432
|
+
if (result.stopping.honoured) {
|
|
2433
|
+
console.log(` ${c.dim(wrap(t.experiment.honoured(n(result.stopping.declared)), 74, ' '))}`);
|
|
2434
|
+
} else {
|
|
2435
|
+
const short = result.stopping.short === result.a.name ? result.a : result.b;
|
|
2436
|
+
console.log(
|
|
2437
|
+
` ${c.yellow('!')} ${wrap(
|
|
2438
|
+
t.experiment.peeked(short.name, n(result.stopping.declared), n(short.recorded)),
|
|
2439
|
+
74,
|
|
2440
|
+
' ',
|
|
2441
|
+
)}`,
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
if (result.marginal !== null) {
|
|
2446
|
+
console.log();
|
|
2447
|
+
console.log(
|
|
2448
|
+
` ${wrap(
|
|
2449
|
+
result.marginal.usdPerExtraSuccess !== null
|
|
2450
|
+
? t.experiment.marginalDearer(
|
|
2451
|
+
result.marginal.better,
|
|
2452
|
+
formatUsd(result.marginal.usdPerExtraSuccess),
|
|
2453
|
+
)
|
|
2454
|
+
: t.experiment.marginalCheaper(result.marginal.better),
|
|
2455
|
+
74,
|
|
2456
|
+
' ',
|
|
2457
|
+
)}`,
|
|
2458
|
+
);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
console.log();
|
|
2462
|
+
console.log(` ${c.dim(wrap(t.experiment.neverPromotes(), 74, ' '))}`);
|
|
2463
|
+
console.log();
|
|
2464
|
+
}
|
|
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
|
+
|
|
2327
2612
|
function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
|
|
2328
2613
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2329
2614
|
const col = t.models.columns;
|
|
@@ -8524,6 +8809,12 @@ async function main(): Promise<void> {
|
|
|
8524
8809
|
case 'models':
|
|
8525
8810
|
commandModels(t, pricing);
|
|
8526
8811
|
break;
|
|
8812
|
+
case 'quality':
|
|
8813
|
+
await commandQuality(args, config, pricing, t);
|
|
8814
|
+
break;
|
|
8815
|
+
case 'experiment':
|
|
8816
|
+
await commandExperiment(args, config, pricing, t);
|
|
8817
|
+
break;
|
|
8527
8818
|
case 'ladder':
|
|
8528
8819
|
await commandLadder(args, config, pricing, t);
|
|
8529
8820
|
break;
|