@trazum/cli 1.50.5 → 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/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +68 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +68 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +32 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +196 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +87 -0
- package/src/i18n/es.ts +87 -0
- package/src/i18n/types.ts +34 -0
- package/src/index.ts +275 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trazum/cli",
|
|
3
|
-
"version": "1.50.
|
|
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.
|
|
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,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 ladder <log>
|
|
61
63
|
trazum feedback
|
|
62
64
|
trazum --version
|
|
63
65
|
|
|
@@ -118,6 +120,18 @@ ${bold('OPTIONS FOR gateway')}
|
|
|
118
120
|
|
|
119
121
|
Your credential is forwarded untouched and never read. See docs/gateway.md.
|
|
120
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
|
+
|
|
121
135
|
${bold('OPTIONS FOR prune')}
|
|
122
136
|
--cases <file> One input per line, or a JSON array. Required.
|
|
123
137
|
--yes Actually spend the calls. Without it the estimate is
|
|
@@ -597,6 +611,12 @@ ${bold('CONFIG FILE')}
|
|
|
597
611
|
spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — money budgets for
|
|
598
612
|
"trazum profile", in dollars. A budgeted label with no calls in
|
|
599
613
|
the log is reported as not measured, never as a pass
|
|
614
|
+
ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
|
|
615
|
+
"escalateOn": ["escalated"] } } — cheap model first, escalate a
|
|
616
|
+
recorded failure to a dearer one. Both fields required.
|
|
617
|
+
"trazum ladder <log>" prints the break-even escalation rate
|
|
618
|
+
beside the measured one: an escalation pays twice, so above that
|
|
619
|
+
rate the ladder costs more than never having built it
|
|
600
620
|
outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
|
|
601
621
|
your own vocabulary for what happened, and which of it counts as
|
|
602
622
|
a win. Both required: which words mean success is a judgement
|
|
@@ -957,6 +977,73 @@ ${bold('EXAMPLES')}
|
|
|
957
977
|
`${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
|
|
958
978
|
},
|
|
959
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
|
+
|
|
1006
|
+
ladder: {
|
|
1007
|
+
heading: () => 'Escalation ladders',
|
|
1008
|
+
noLadders: () =>
|
|
1009
|
+
'No ladders configured. A ladder sends a workload to a cheap model first and escalates a recorded failure to a dearer one — add "ladders" to trazum.config.json, for example {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
|
|
1010
|
+
workload: (label) => label,
|
|
1011
|
+
arithmetic: (cheap, dear, breakEven) =>
|
|
1012
|
+
`${cheap} a call cheap, ${dear} dear. Break-even escalation rate: ${breakEven}.`,
|
|
1013
|
+
measured: (rate, escalations, calls) =>
|
|
1014
|
+
`Measured: ${rate} (${escalations} of ${calls} calls escalated).`,
|
|
1015
|
+
saving: (delta) => `Saving ${delta} a call against never having built it.`,
|
|
1016
|
+
costing: (delta) =>
|
|
1017
|
+
`Costing ${delta} a call MORE than never having built it. Escalating above the break-even rate means paying for the cheap attempt and the dear one on most calls.`,
|
|
1018
|
+
atBreakEven: (band) =>
|
|
1019
|
+
`Within ${band} of break-even, so no sign is claimed. Inside that band the answer flips on ordinary week-to-week variation, and "saving" on Monday and "costing" on Thursday from the same policy teaches a reader to ignore the figure.`,
|
|
1020
|
+
cannotTell: (why, calls) =>
|
|
1021
|
+
why === 'too-few-calls'
|
|
1022
|
+
? `Cannot tell yet: ${calls} calls carried a declared outcome, and a rate over that few moves more from one more call than from anything you could do about it.`
|
|
1023
|
+
: why === 'no-outcomes-recorded'
|
|
1024
|
+
? 'Cannot tell: nothing in this log recorded an outcome for this workload, so there is no escalation rate to compare against.'
|
|
1025
|
+
: why === 'tier-unpriced'
|
|
1026
|
+
? 'Cannot tell: one of the tiers is not in the price catalogue, so the break-even rate cannot be computed.'
|
|
1027
|
+
: 'Cannot tell: this ladder declares no escalation values.',
|
|
1028
|
+
problemsHeading: (label) => `${label} — this ladder will not do what it looks like it does`,
|
|
1029
|
+
problem: (kind, detail) =>
|
|
1030
|
+
kind === 'escalate-on-a-success'
|
|
1031
|
+
? `escalateOn names "${detail}", which "outcomes.success" declares a SUCCESS. This ladder pays twice for work that already worked, on every call, while looking exactly like a cost-saving measure.`
|
|
1032
|
+
: kind === 'escalate-on-undeclared'
|
|
1033
|
+
? `escalateOn names "${detail}", which "outcomes.values" does not declare. This ladder never fires, silently.`
|
|
1034
|
+
: kind === 'tiers-not-cheapest-first'
|
|
1035
|
+
? `"${detail}" is cheaper than the tier before it. That is not a ladder; it is a routing rule that escalates to something cheaper and reports a saving for it.`
|
|
1036
|
+
: kind === 'duplicate-tier'
|
|
1037
|
+
? `"${detail}" appears twice in tiers.`
|
|
1038
|
+
: kind === 'unknown-model'
|
|
1039
|
+
? `"${detail}" is not in the price catalogue.`
|
|
1040
|
+
: `a ladder needs at least two tiers; this one has ${detail}.`,
|
|
1041
|
+
theDoubleSpend: () =>
|
|
1042
|
+
'An escalation pays twice: the cheap attempt is not refunded. So a ladder saves money only below its break-even escalation rate, and above it costs more than never having built one — which is why the rate is printed beside the measurement rather than left to be worked out in somebody\u2019s head.',
|
|
1043
|
+
notExecuted: () =>
|
|
1044
|
+
'Trazum does not run the escalation. A ladder escalates after a failure is known, which is after the answer came back and usually after something downstream judged it, so the retry belongs in your own loop. What is here is the policy and the arithmetic that says whether the policy is worth running.',
|
|
1045
|
+
},
|
|
1046
|
+
|
|
960
1047
|
gateway: {
|
|
961
1048
|
badProvider: (given, known) =>
|
|
962
1049
|
given === ''
|
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 ladder <log>
|
|
48
50
|
trazum feedback
|
|
49
51
|
trazum --version
|
|
50
52
|
|
|
@@ -108,6 +110,18 @@ ${bold('OPCIONES DE gateway')}
|
|
|
108
110
|
|
|
109
111
|
Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
|
|
110
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
|
+
|
|
111
125
|
${bold('OPCIONES DE prune')}
|
|
112
126
|
--cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
|
|
113
127
|
--yes Gasta las llamadas de verdad. Sin él se imprime la
|
|
@@ -617,6 +631,12 @@ ${bold('FICHERO DE CONFIGURACIÓN')}
|
|
|
617
631
|
spend { "maxUsd": 200, "byLabel": { "chat": 40 } } — presupuestos en
|
|
618
632
|
dólares para "trazum profile". Una etiqueta con presupuesto y sin
|
|
619
633
|
llamadas se informa como no medida, nunca como aprobada
|
|
634
|
+
ladders { "support": { "tiers": ["claude-haiku-4-5", "claude-opus-5"],
|
|
635
|
+
"escalateOn": ["escalated"] } } — modelo barato primero, escalar
|
|
636
|
+
un fallo registrado a uno más caro. Los dos campos obligatorios.
|
|
637
|
+
"trazum ladder <log>" imprime la tasa de escalado de equilibrio
|
|
638
|
+
junto a la medida: un escalado paga dos veces, así que por encima
|
|
639
|
+
de esa tasa la escalera cuesta más que no haberla construido
|
|
620
640
|
outcomes { "values": ["resolved", "escalated"], "success": ["resolved"] } —
|
|
621
641
|
tu propio vocabulario para lo que pasó, y cuál de él cuenta como
|
|
622
642
|
acierto. Los dos son obligatorios: qué palabras significan éxito
|
|
@@ -993,6 +1013,73 @@ ${bold('EJEMPLOS')}
|
|
|
993
1013
|
`${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
|
|
994
1014
|
},
|
|
995
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
|
+
|
|
1042
|
+
ladder: {
|
|
1043
|
+
heading: () => 'Escaleras de escalado',
|
|
1044
|
+
noLadders: () =>
|
|
1045
|
+
'No hay escaleras configuradas. Una escalera manda una carga a un modelo barato primero y escala un fallo registrado a uno m\u00e1s caro — a\u00f1ade "ladders" a trazum.config.json, por ejemplo {"support": {"tiers": ["claude-haiku-4-5", "claude-opus-5"], "escalateOn": ["escalated"]}}.',
|
|
1046
|
+
workload: (label) => label,
|
|
1047
|
+
arithmetic: (cheap, dear, breakEven) =>
|
|
1048
|
+
`${cheap} por llamada barata, ${dear} cara. Tasa de escalado de equilibrio: ${breakEven}.`,
|
|
1049
|
+
measured: (rate, escalations, calls) =>
|
|
1050
|
+
`Medido: ${rate} (${escalations} de ${calls} llamadas escalaron).`,
|
|
1051
|
+
saving: (delta) => `Ahorra ${delta} por llamada frente a no haberla construido.`,
|
|
1052
|
+
costing: (delta) =>
|
|
1053
|
+
`Cuesta ${delta} por llamada M\u00c1S que no haberla construido. Escalar por encima de la tasa de equilibrio significa pagar el intento barato y el caro en la mayor\u00eda de las llamadas.`,
|
|
1054
|
+
atBreakEven: (band) =>
|
|
1055
|
+
`A menos de ${band} del equilibrio, as\u00ed que no se afirma ning\u00fan signo. Dentro de esa banda la respuesta cambia con la variaci\u00f3n normal de una semana a otra, y decir "ahorra" el lunes y "cuesta" el jueves con la misma pol\u00edtica ense\u00f1a a ignorar la cifra.`,
|
|
1056
|
+
cannotTell: (why, calls) =>
|
|
1057
|
+
why === 'too-few-calls'
|
|
1058
|
+
? `A\u00fan no se puede decir: ${calls} llamadas llevaron un resultado declarado, y una tasa sobre tan pocas se mueve m\u00e1s con una llamada m\u00e1s que con nada que pudieras hacer.`
|
|
1059
|
+
: why === 'no-outcomes-recorded'
|
|
1060
|
+
? 'No se puede decir: nada en este log registr\u00f3 un resultado para esta carga, as\u00ed que no hay tasa de escalado con la que comparar.'
|
|
1061
|
+
: why === 'tier-unpriced'
|
|
1062
|
+
? 'No se puede decir: uno de los pelda\u00f1os no est\u00e1 en la tabla de precios, as\u00ed que la tasa de equilibrio no se puede calcular.'
|
|
1063
|
+
: 'No se puede decir: esta escalera no declara valores de escalado.',
|
|
1064
|
+
problemsHeading: (label) => `${label} — esta escalera no va a hacer lo que parece`,
|
|
1065
|
+
problem: (kind, detail) =>
|
|
1066
|
+
kind === 'escalate-on-a-success'
|
|
1067
|
+
? `escalateOn nombra "${detail}", que "outcomes.success" declara como \u00c9XITO. Esta escalera paga dos veces por trabajo que ya funcion\u00f3, en cada llamada, con el aspecto exacto de una medida de ahorro.`
|
|
1068
|
+
: kind === 'escalate-on-undeclared'
|
|
1069
|
+
? `escalateOn nombra "${detail}", que "outcomes.values" no declara. Esta escalera no se dispara nunca, en silencio.`
|
|
1070
|
+
: kind === 'tiers-not-cheapest-first'
|
|
1071
|
+
? `"${detail}" es m\u00e1s barato que el pelda\u00f1o anterior. Eso no es una escalera; es una regla de enrutado que escala a algo m\u00e1s barato y lo reporta como ahorro.`
|
|
1072
|
+
: kind === 'duplicate-tier'
|
|
1073
|
+
? `"${detail}" aparece dos veces en tiers.`
|
|
1074
|
+
: kind === 'unknown-model'
|
|
1075
|
+
? `"${detail}" no est\u00e1 en la tabla de precios.`
|
|
1076
|
+
: `una escalera necesita al menos dos pelda\u00f1os; esta tiene ${detail}.`,
|
|
1077
|
+
theDoubleSpend: () =>
|
|
1078
|
+
'Un escalado paga dos veces: el intento barato no se devuelve. As\u00ed que una escalera solo ahorra por debajo de su tasa de escalado de equilibrio, y por encima cuesta m\u00e1s que no haberla construido — por eso la tasa se imprime junto a la medici\u00f3n en vez de dejarla para calcularla de cabeza.',
|
|
1079
|
+
notExecuted: () =>
|
|
1080
|
+
'Trazum no ejecuta el escalado. Una escalera escala despu\u00e9s de conocer un fallo, que es despu\u00e9s de que llegue la respuesta y normalmente despu\u00e9s de que algo la juzgue, as\u00ed que el reintento va en tu propio bucle. Lo que hay aqu\u00ed es la pol\u00edtica y la aritm\u00e9tica que dice si merece la pena.',
|
|
1081
|
+
},
|
|
1082
|
+
|
|
996
1083
|
gateway: {
|
|
997
1084
|
badProvider: (given, known) =>
|
|
998
1085
|
given === ''
|
package/src/i18n/types.ts
CHANGED
|
@@ -264,6 +264,40 @@ export interface CliMessages {
|
|
|
264
264
|
* between somebody and their provider is trusted on nothing but what it says
|
|
265
265
|
* plainly at start-up.
|
|
266
266
|
*/
|
|
267
|
+
/**
|
|
268
|
+
* The ladder. Every line here exists to stop somebody reading "we route to
|
|
269
|
+
* the cheap model first" as a saving without the number that decides it.
|
|
270
|
+
*/
|
|
271
|
+
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
|
+
|
|
285
|
+
ladder: {
|
|
286
|
+
heading(): string;
|
|
287
|
+
noLadders(): string;
|
|
288
|
+
workload(label: string): string;
|
|
289
|
+
arithmetic(cheap: string, dear: string, breakEven: string): string;
|
|
290
|
+
measured(rate: string, escalations: string, calls: string): string;
|
|
291
|
+
saving(delta: string): string;
|
|
292
|
+
costing(delta: string): string;
|
|
293
|
+
atBreakEven(band: string): string;
|
|
294
|
+
cannotTell(why: string, calls: string): string;
|
|
295
|
+
problem(kind: string, detail: string): string;
|
|
296
|
+
problemsHeading(label: string): string;
|
|
297
|
+
theDoubleSpend(): string;
|
|
298
|
+
notExecuted(): string;
|
|
299
|
+
};
|
|
300
|
+
|
|
267
301
|
gateway: {
|
|
268
302
|
badProvider(given: string, known: string): string;
|
|
269
303
|
needsPolicy(policies: string): string;
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
DEFAULT_USAGE,
|
|
40
40
|
budgetPositions,
|
|
41
41
|
conform,
|
|
42
|
+
BREAK_EVEN_BAND,
|
|
43
|
+
runExperiment,
|
|
44
|
+
ladderPosition,
|
|
45
|
+
validateLadder,
|
|
42
46
|
outcomeReport,
|
|
43
47
|
rankPerOutcome,
|
|
44
48
|
FAILURE_POLICIES,
|
|
@@ -136,6 +140,7 @@ import type {
|
|
|
136
140
|
import type {
|
|
137
141
|
BudgetReport,
|
|
138
142
|
ContractName,
|
|
143
|
+
ExperimentArm,
|
|
139
144
|
FailurePolicy,
|
|
140
145
|
GatewayStanding,
|
|
141
146
|
UsageProfileReport,
|
|
@@ -242,6 +247,9 @@ interface Args {
|
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
const VALUE_FLAGS = new Set([
|
|
250
|
+
'a',
|
|
251
|
+
'b',
|
|
252
|
+
'min-outcomes',
|
|
245
253
|
'against',
|
|
246
254
|
'contract',
|
|
247
255
|
'on-cannot-tell',
|
|
@@ -570,6 +578,8 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
570
578
|
conform: ['contract', 'json'],
|
|
571
579
|
feedback: [],
|
|
572
580
|
gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
|
|
581
|
+
ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
|
|
582
|
+
experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
|
|
573
583
|
where: [],
|
|
574
584
|
rules: [],
|
|
575
585
|
blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
|
|
@@ -2190,6 +2200,265 @@ async function commandGateway(
|
|
|
2190
2200
|
console.log();
|
|
2191
2201
|
}
|
|
2192
2202
|
|
|
2203
|
+
/**
|
|
2204
|
+
* `trazum ladder <log>` — is the ladder saving money, or is it a bill?
|
|
2205
|
+
*
|
|
2206
|
+
* The one number this command exists to print is the **break-even escalation
|
|
2207
|
+
* rate**. "We route to the cheap model first" describes a policy that saves
|
|
2208
|
+
* money and a policy that costs money equally well; only the rate separates
|
|
2209
|
+
* them, and nobody works it out in their head because the shape of the
|
|
2210
|
+
* arithmetic is not obvious — an escalation pays twice, since the cheap
|
|
2211
|
+
* attempt is not refunded.
|
|
2212
|
+
*/
|
|
2213
|
+
async function commandLadder(
|
|
2214
|
+
args: Args,
|
|
2215
|
+
config: TrazumConfig,
|
|
2216
|
+
pricing: PricingCatalogue,
|
|
2217
|
+
t: CliMessages,
|
|
2218
|
+
): Promise<void> {
|
|
2219
|
+
const path = args.positional[0];
|
|
2220
|
+
if (path === undefined) {
|
|
2221
|
+
throw new Error(t.errors.missingInputFile());
|
|
2222
|
+
}
|
|
2223
|
+
const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
|
|
2224
|
+
const ladders = config.ladders ?? {};
|
|
2225
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2226
|
+
const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
|
|
2227
|
+
|
|
2228
|
+
console.log();
|
|
2229
|
+
console.log(c.bold(t.ladder.heading()));
|
|
2230
|
+
if (Object.keys(ladders).length === 0) {
|
|
2231
|
+
console.log(` ${c.dim(wrap(t.ladder.noLadders(), 74, ' '))}`);
|
|
2232
|
+
console.log();
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
console.log(` ${c.dim(wrap(t.ladder.theDoubleSpend(), 74, ' '))}`);
|
|
2236
|
+
console.log();
|
|
2237
|
+
|
|
2238
|
+
const vocabulary = config.outcomes ?? null;
|
|
2239
|
+
let anyProblem = false;
|
|
2240
|
+
|
|
2241
|
+
for (const [label, policy] of Object.entries(ladders)) {
|
|
2242
|
+
/**
|
|
2243
|
+
* Validated before it is measured, and loudly.
|
|
2244
|
+
*
|
|
2245
|
+
* A ladder that escalates on a value declared a *success* pays twice for
|
|
2246
|
+
* work that already worked, on every call, while looking exactly like a
|
|
2247
|
+
* cost-saving measure in the config. Printing its measured position first
|
|
2248
|
+
* would bury that under a number.
|
|
2249
|
+
*/
|
|
2250
|
+
const problems = validateLadder(policy, vocabulary, pricing);
|
|
2251
|
+
if (problems.length > 0) {
|
|
2252
|
+
anyProblem = true;
|
|
2253
|
+
console.log(` ${c.red('✗')} ${c.bold(t.ladder.problemsHeading(label))}`);
|
|
2254
|
+
for (const problem of problems) {
|
|
2255
|
+
const detail =
|
|
2256
|
+
'value' in problem
|
|
2257
|
+
? problem.value
|
|
2258
|
+
: 'model' in problem
|
|
2259
|
+
? problem.model
|
|
2260
|
+
: String(problem.tiers);
|
|
2261
|
+
console.log(` ${wrap(t.ladder.problem(problem.kind, detail), 70, ' ')}`);
|
|
2262
|
+
}
|
|
2263
|
+
console.log();
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
|
|
2268
|
+
const breakdown = report.byLabel.find((entry) => entry.label === label);
|
|
2269
|
+
/**
|
|
2270
|
+
* The shape of the work comes from the measured calls, so the break-even
|
|
2271
|
+
* rate is priced against what this workload actually sends rather than
|
|
2272
|
+
* against a token count somebody guessed at.
|
|
2273
|
+
*/
|
|
2274
|
+
const calls = breakdown?.breakdown.calls ?? 0;
|
|
2275
|
+
const shape =
|
|
2276
|
+
breakdown === undefined || calls === 0
|
|
2277
|
+
? { inputTokens: 0, outputTokens: 0 }
|
|
2278
|
+
: {
|
|
2279
|
+
inputTokens: Math.round(
|
|
2280
|
+
(breakdown.breakdown.inputTokens +
|
|
2281
|
+
breakdown.breakdown.cacheReadTokens +
|
|
2282
|
+
breakdown.breakdown.cacheWriteTokens) /
|
|
2283
|
+
calls,
|
|
2284
|
+
),
|
|
2285
|
+
outputTokens: Math.round(breakdown.breakdown.outputTokens / calls),
|
|
2286
|
+
};
|
|
2287
|
+
|
|
2288
|
+
const empty = { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 };
|
|
2289
|
+
const position = ladderPosition(policy, slice?.tally ?? empty, shape, vocabulary, pricing);
|
|
2290
|
+
|
|
2291
|
+
console.log(` ${c.bold(t.ladder.workload(label))} ${c.dim(policy.tiers.join(' → '))}`);
|
|
2292
|
+
console.log(
|
|
2293
|
+
` ${c.dim(
|
|
2294
|
+
t.ladder.arithmetic(
|
|
2295
|
+
formatUsd(position.arithmetic.cheapUsd),
|
|
2296
|
+
formatUsd(position.arithmetic.dearUsd),
|
|
2297
|
+
position.arithmetic.breakEvenRate === null ? '—' : pct(position.arithmetic.breakEvenRate),
|
|
2298
|
+
),
|
|
2299
|
+
)}`,
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
if (position.verdict === 'cannot-tell') {
|
|
2303
|
+
console.log(
|
|
2304
|
+
` ${c.yellow('?')} ${wrap(t.ladder.cannotTell(position.unknown ?? '', n(position.calls)), 70, ' ')}`,
|
|
2305
|
+
);
|
|
2306
|
+
} else {
|
|
2307
|
+
console.log(
|
|
2308
|
+
` ${t.ladder.measured(pct(position.measuredRate ?? 0), n(position.escalations), n(position.calls))}`,
|
|
2309
|
+
);
|
|
2310
|
+
const delta = formatUsd(Math.abs(position.deltaUsdPerCall ?? 0));
|
|
2311
|
+
if (position.verdict === 'saving') {
|
|
2312
|
+
console.log(` ${c.green('✓')} ${wrap(t.ladder.saving(delta), 70, ' ')}`);
|
|
2313
|
+
} else if (position.verdict === 'costing') {
|
|
2314
|
+
console.log(` ${c.red('✗')} ${wrap(t.ladder.costing(delta), 70, ' ')}`);
|
|
2315
|
+
} else {
|
|
2316
|
+
console.log(` ${c.dim('·')} ${wrap(t.ladder.atBreakEven(pct(BREAK_EVEN_BAND)), 70, ' ')}`);
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
console.log();
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
console.log(` ${c.dim(wrap(t.ladder.notExecuted(), 74, ' '))}`);
|
|
2323
|
+
console.log();
|
|
2324
|
+
|
|
2325
|
+
/**
|
|
2326
|
+
* A misconfigured ladder fails the command, because it is the one finding
|
|
2327
|
+
* here that is wrong *now* rather than a measurement somebody should look
|
|
2328
|
+
* at. Everything else exits 0: this is a survey, like `doctor`.
|
|
2329
|
+
*/
|
|
2330
|
+
if (anyProblem) process.exitCode = 1;
|
|
2331
|
+
}
|
|
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
|
+
|
|
2193
2462
|
function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
|
|
2194
2463
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2195
2464
|
const col = t.models.columns;
|
|
@@ -8390,6 +8659,12 @@ async function main(): Promise<void> {
|
|
|
8390
8659
|
case 'models':
|
|
8391
8660
|
commandModels(t, pricing);
|
|
8392
8661
|
break;
|
|
8662
|
+
case 'experiment':
|
|
8663
|
+
await commandExperiment(args, config, pricing, t);
|
|
8664
|
+
break;
|
|
8665
|
+
case 'ladder':
|
|
8666
|
+
await commandLadder(args, config, pricing, t);
|
|
8667
|
+
break;
|
|
8393
8668
|
case 'gateway':
|
|
8394
8669
|
await commandGateway(args, config, configDir, pricing, t);
|
|
8395
8670
|
break;
|