@trazum/cli 1.50.6 → 1.50.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +30 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +30 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/types.d.ts +13 -0
- package/dist/i18n/types.d.ts.map +1 -1
- package/dist/index.js +89 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/i18n/en.ts +39 -0
- package/src/i18n/es.ts +39 -0
- package/src/i18n/types.ts +14 -0
- package/src/index.ts +138 -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,7 @@ ${bold('USAGE')}
|
|
|
58
58
|
trazum models
|
|
59
59
|
trazum rules
|
|
60
60
|
trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
|
|
61
|
+
trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
|
|
61
62
|
trazum ladder <log>
|
|
62
63
|
trazum feedback
|
|
63
64
|
trazum --version
|
|
@@ -119,6 +120,18 @@ ${bold('OPTIONS FOR gateway')}
|
|
|
119
120
|
|
|
120
121
|
Your credential is forwarded untouched and never read. See docs/gateway.md.
|
|
121
122
|
|
|
123
|
+
${bold('OPTIONS FOR experiment')}
|
|
124
|
+
--a <label>, --b <label> The two workloads to compare.
|
|
125
|
+
--min-outcomes <n> Required: how many outcomes each arm must record
|
|
126
|
+
before the result may be read. A stopping rule
|
|
127
|
+
declared after looking at the numbers is not a
|
|
128
|
+
stopping rule, and the report says whether it was
|
|
129
|
+
honoured.
|
|
130
|
+
|
|
131
|
+
Judges recorded outcomes and cost together. Three-valued: A wins, B wins, or
|
|
132
|
+
not separable — with the number of outcomes per arm that would settle it, so
|
|
133
|
+
"run it longer" is an instruction rather than a shrug. Nothing is promoted.
|
|
134
|
+
|
|
122
135
|
${bold('OPTIONS FOR prune')}
|
|
123
136
|
--cases <file> One input per line, or a JSON array. Required.
|
|
124
137
|
--yes Actually spend the calls. Without it the estimate is
|
|
@@ -964,6 +977,32 @@ ${bold('EXAMPLES')}
|
|
|
964
977
|
`${path} exists and could not be parsed, so nothing was written over it. Fix or move it first.`,
|
|
965
978
|
},
|
|
966
979
|
|
|
980
|
+
experiment: {
|
|
981
|
+
heading: (a, b) => `Experiment: ${a} against ${b}`,
|
|
982
|
+
needsTwo: () =>
|
|
983
|
+
'Name two labels to compare, for example: trazum experiment <log> --a prompt-v1 --b prompt-v2',
|
|
984
|
+
needsRule: () =>
|
|
985
|
+
'--min-outcomes is required, and it is the point: a stopping rule declared after looking at the numbers is not a stopping rule. Say how many outcomes each arm must record before the result may be read.',
|
|
986
|
+
arm: (name, rate, successes, recorded, interval) =>
|
|
987
|
+
`${name} ${rate} (${successes} of ${recorded} recorded) 95% ${interval}`,
|
|
988
|
+
wins: (name, low, high) =>
|
|
989
|
+
`${name} wins. The difference is between ${low} and ${high} at 95% confidence \u2014 the whole interval is on one side of zero, which is what "wins" means here.`,
|
|
990
|
+
notSeparable: (why, needed) =>
|
|
991
|
+
why === 'no-difference-observed'
|
|
992
|
+
? 'Not separable: both arms recorded the same rate. No sample size separates a difference of zero, so there is no "run it longer" to offer \u2014 there is nothing here to find.'
|
|
993
|
+
: why === 'nothing-recorded'
|
|
994
|
+
? 'Not separable: an arm recorded no outcomes at all, so there is no rate to compare.'
|
|
995
|
+
: `Not separable on this traffic: the 95% interval on the difference includes zero. One number is larger, and that is not a finding. About ${needed} outcomes per arm would settle the difference observed so far.`,
|
|
996
|
+
peeked: (short, declared, recorded) =>
|
|
997
|
+
`Read early. The declared rule was ${declared} outcomes per arm and ${short} has ${recorded}. Nothing can stop a number being read early; this line exists so whoever reads the result later can see that it was.`,
|
|
998
|
+
honoured: (declared) => `Stopping rule honoured: both arms cleared ${declared} recorded outcomes.`,
|
|
999
|
+
marginalDearer: (better, usd) =>
|
|
1000
|
+
`${better} resolves more and costs more. One extra success costs ${usd} \u2014 that figure, not the rate, is what the decision turns on.`,
|
|
1001
|
+
marginalCheaper: (better) => `${better} resolves more AND costs less per call. Nothing is being traded.`,
|
|
1002
|
+
neverPromotes: () =>
|
|
1003
|
+
'Nothing was changed. A winner is a finding; taking it is a decision with a name attached, and it belongs in the plan like everything else.',
|
|
1004
|
+
},
|
|
1005
|
+
|
|
967
1006
|
ladder: {
|
|
968
1007
|
heading: () => 'Escalation ladders',
|
|
969
1008
|
noLadders: () =>
|
package/src/i18n/es.ts
CHANGED
|
@@ -45,6 +45,7 @@ ${bold('USO')}
|
|
|
45
45
|
trazum models
|
|
46
46
|
trazum rules
|
|
47
47
|
trazum gateway <anthropic|openai> --on-cannot-tell <fail-open|fail-closed>
|
|
48
|
+
trazum experiment <log> --a <label> --b <label> --min-outcomes <n>
|
|
48
49
|
trazum ladder <log>
|
|
49
50
|
trazum feedback
|
|
50
51
|
trazum --version
|
|
@@ -109,6 +110,18 @@ ${bold('OPCIONES DE gateway')}
|
|
|
109
110
|
|
|
110
111
|
Tu credencial se reenvía intacta y nunca se lee. Ver docs/gateway.md.
|
|
111
112
|
|
|
113
|
+
${bold('OPCIONES DE experiment')}
|
|
114
|
+
--a <etiqueta>, --b <etiqueta> Las dos cargas a comparar.
|
|
115
|
+
--min-outcomes <n> Obligatorio: cuántos resultados debe registrar
|
|
116
|
+
cada brazo antes de poder leer el resultado. Una
|
|
117
|
+
regla de parada declarada después de mirar los
|
|
118
|
+
números no es una regla de parada, y el informe
|
|
119
|
+
dice si se respetó.
|
|
120
|
+
|
|
121
|
+
Juzga resultados registrados y coste a la vez. Tres valores: gana A, gana B,
|
|
122
|
+
o no separables — con cuántos resultados por brazo lo zanjarían, para que
|
|
123
|
+
"déjalo correr más" sea una instrucción y no un encogimiento de hombros.
|
|
124
|
+
|
|
112
125
|
${bold('OPCIONES DE prune')}
|
|
113
126
|
--cases <fichero> Una entrada por línea, o un array JSON. Obligatorio.
|
|
114
127
|
--yes Gasta las llamadas de verdad. Sin él se imprime la
|
|
@@ -1000,6 +1013,32 @@ ${bold('EJEMPLOS')}
|
|
|
1000
1013
|
`${path} existe y no se pudo interpretar, así que no se escribió nada encima. Arréglalo o muévelo primero.`,
|
|
1001
1014
|
},
|
|
1002
1015
|
|
|
1016
|
+
experiment: {
|
|
1017
|
+
heading: (a, b) => `Experimento: ${a} contra ${b}`,
|
|
1018
|
+
needsTwo: () =>
|
|
1019
|
+
'Nombra dos etiquetas a comparar, por ejemplo: trazum experiment <log> --a prompt-v1 --b prompt-v2',
|
|
1020
|
+
needsRule: () =>
|
|
1021
|
+
'--min-outcomes es obligatorio, y ese es justo el punto: una regla de parada declarada despu\u00e9s de mirar los n\u00fameros no es una regla de parada. Di cu\u00e1ntos resultados debe registrar cada brazo antes de poder leer el resultado.',
|
|
1022
|
+
arm: (name, rate, successes, recorded, interval) =>
|
|
1023
|
+
`${name} ${rate} (${successes} de ${recorded} registrados) 95% ${interval}`,
|
|
1024
|
+
wins: (name, low, high) =>
|
|
1025
|
+
`Gana ${name}. La diferencia est\u00e1 entre ${low} y ${high} con un 95% de confianza \u2014 el intervalo entero est\u00e1 a un lado del cero, que es lo que significa "gana" aqu\u00ed.`,
|
|
1026
|
+
notSeparable: (why, needed) =>
|
|
1027
|
+
why === 'no-difference-observed'
|
|
1028
|
+
? 'No separables: los dos brazos registraron la misma tasa. Ning\u00fan tama\u00f1o de muestra separa una diferencia de cero, as\u00ed que no hay ning\u00fan "d\u00e9jalo correr m\u00e1s" que ofrecer \u2014 no hay nada que encontrar.'
|
|
1029
|
+
: why === 'nothing-recorded'
|
|
1030
|
+
? 'No separables: un brazo no registr\u00f3 ning\u00fan resultado, as\u00ed que no hay tasa que comparar.'
|
|
1031
|
+
: `No separables con este tr\u00e1fico: el intervalo del 95% sobre la diferencia incluye el cero. Un n\u00famero es mayor, y eso no es un hallazgo. Unos ${needed} resultados por brazo zanjar\u00edan la diferencia observada hasta ahora.`,
|
|
1032
|
+
peeked: (short, declared, recorded) =>
|
|
1033
|
+
`Le\u00eddo antes de tiempo. La regla declarada eran ${declared} resultados por brazo y ${short} tiene ${recorded}. Nada puede impedir que se lea un n\u00famero antes de tiempo; esta l\u00ednea existe para que quien lea el resultado despu\u00e9s vea que se hizo.`,
|
|
1034
|
+
honoured: (declared) => `Regla de parada respetada: los dos brazos superaron ${declared} resultados registrados.`,
|
|
1035
|
+
marginalDearer: (better, usd) =>
|
|
1036
|
+
`${better} resuelve m\u00e1s y cuesta m\u00e1s. Un acierto extra cuesta ${usd} \u2014 esa cifra, y no la tasa, es de lo que depende la decisi\u00f3n.`,
|
|
1037
|
+
marginalCheaper: (better) => `${better} resuelve m\u00e1s Y cuesta menos por llamada. No se est\u00e1 cambiando nada por nada.`,
|
|
1038
|
+
neverPromotes: () =>
|
|
1039
|
+
'No se cambi\u00f3 nada. Un ganador es un hallazgo; tomarlo es una decisi\u00f3n con un nombre detr\u00e1s, y va en el plan como todo lo dem\u00e1s.',
|
|
1040
|
+
},
|
|
1041
|
+
|
|
1003
1042
|
ladder: {
|
|
1004
1043
|
heading: () => 'Escaleras de escalado',
|
|
1005
1044
|
noLadders: () =>
|
package/src/i18n/types.ts
CHANGED
|
@@ -268,6 +268,20 @@ export interface CliMessages {
|
|
|
268
268
|
* The ladder. Every line here exists to stop somebody reading "we route to
|
|
269
269
|
* the cheap model first" as a saving without the number that decides it.
|
|
270
270
|
*/
|
|
271
|
+
experiment: {
|
|
272
|
+
heading(a: string, b: string): string;
|
|
273
|
+
needsTwo(): string;
|
|
274
|
+
needsRule(): string;
|
|
275
|
+
arm(name: string, rate: string, successes: string, recorded: string, interval: string): string;
|
|
276
|
+
wins(name: string, low: string, high: string): string;
|
|
277
|
+
notSeparable(why: string, needed: string): string;
|
|
278
|
+
peeked(short: string, declared: string, recorded: string): string;
|
|
279
|
+
honoured(declared: string): string;
|
|
280
|
+
marginalDearer(better: string, usd: string): string;
|
|
281
|
+
marginalCheaper(better: string): string;
|
|
282
|
+
neverPromotes(): string;
|
|
283
|
+
};
|
|
284
|
+
|
|
271
285
|
ladder: {
|
|
272
286
|
heading(): string;
|
|
273
287
|
noLadders(): string;
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
budgetPositions,
|
|
41
41
|
conform,
|
|
42
42
|
BREAK_EVEN_BAND,
|
|
43
|
+
runExperiment,
|
|
43
44
|
ladderPosition,
|
|
44
45
|
validateLadder,
|
|
45
46
|
outcomeReport,
|
|
@@ -139,6 +140,7 @@ import type {
|
|
|
139
140
|
import type {
|
|
140
141
|
BudgetReport,
|
|
141
142
|
ContractName,
|
|
143
|
+
ExperimentArm,
|
|
142
144
|
FailurePolicy,
|
|
143
145
|
GatewayStanding,
|
|
144
146
|
UsageProfileReport,
|
|
@@ -245,6 +247,9 @@ interface Args {
|
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
const VALUE_FLAGS = new Set([
|
|
250
|
+
'a',
|
|
251
|
+
'b',
|
|
252
|
+
'min-outcomes',
|
|
248
253
|
'against',
|
|
249
254
|
'contract',
|
|
250
255
|
'on-cannot-tell',
|
|
@@ -574,6 +579,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
574
579
|
feedback: [],
|
|
575
580
|
gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
|
|
576
581
|
ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
|
|
582
|
+
experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
|
|
577
583
|
where: [],
|
|
578
584
|
rules: [],
|
|
579
585
|
blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
|
|
@@ -2324,6 +2330,135 @@ async function commandLadder(
|
|
|
2324
2330
|
if (anyProblem) process.exitCode = 1;
|
|
2325
2331
|
}
|
|
2326
2332
|
|
|
2333
|
+
/**
|
|
2334
|
+
* `trazum experiment <log> --a <label> --b <label> --min-outcomes <n>`
|
|
2335
|
+
*
|
|
2336
|
+
* Two arms on real traffic, judged on recorded outcomes and cost together.
|
|
2337
|
+
*
|
|
2338
|
+
* `--min-outcomes` is required and that is the entire point of it. A stopping
|
|
2339
|
+
* rule declared after looking at the numbers is not a stopping rule, and
|
|
2340
|
+
* nothing here can stop somebody reading a result early — what it can do is
|
|
2341
|
+
* make the early read **visible to whoever reads the result later**, which is
|
|
2342
|
+
* the part that survives the afternoon.
|
|
2343
|
+
*/
|
|
2344
|
+
async function commandExperiment(
|
|
2345
|
+
args: Args,
|
|
2346
|
+
config: TrazumConfig,
|
|
2347
|
+
pricing: PricingCatalogue,
|
|
2348
|
+
t: CliMessages,
|
|
2349
|
+
): Promise<void> {
|
|
2350
|
+
const path = args.positional[0];
|
|
2351
|
+
if (path === undefined) throw new Error(t.errors.missingInputFile());
|
|
2352
|
+
|
|
2353
|
+
const aName = stringFlag(args, 'a');
|
|
2354
|
+
const bName = stringFlag(args, 'b');
|
|
2355
|
+
if (aName === undefined || bName === undefined) throw new Error(t.experiment.needsTwo());
|
|
2356
|
+
|
|
2357
|
+
const minRaw = stringFlag(args, 'min-outcomes');
|
|
2358
|
+
const minOutcomesPerArm = minRaw === undefined ? Number.NaN : Number(minRaw);
|
|
2359
|
+
if (!Number.isInteger(minOutcomesPerArm) || minOutcomesPerArm < 1) {
|
|
2360
|
+
throw new Error(t.experiment.needsRule());
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
|
|
2364
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2365
|
+
const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
|
|
2366
|
+
|
|
2367
|
+
const armOf = (label: string): ExperimentArm => {
|
|
2368
|
+
const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
|
|
2369
|
+
return {
|
|
2370
|
+
name: label,
|
|
2371
|
+
totalUsd: slice?.totalUsd ?? 0,
|
|
2372
|
+
tally: slice?.tally ?? { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 },
|
|
2373
|
+
};
|
|
2374
|
+
};
|
|
2375
|
+
|
|
2376
|
+
const result = runExperiment(
|
|
2377
|
+
{ arms: [aName, bName], minOutcomesPerArm },
|
|
2378
|
+
{ a: armOf(aName), b: armOf(bName) },
|
|
2379
|
+
config.outcomes ?? null,
|
|
2380
|
+
);
|
|
2381
|
+
|
|
2382
|
+
console.log();
|
|
2383
|
+
console.log(c.bold(t.experiment.heading(aName, bName)));
|
|
2384
|
+
console.log();
|
|
2385
|
+
for (const side of [result.a, result.b]) {
|
|
2386
|
+
console.log(
|
|
2387
|
+
` ${t.experiment.arm(
|
|
2388
|
+
side.name,
|
|
2389
|
+
side.rate === null ? '—' : pct(side.rate),
|
|
2390
|
+
n(side.successes),
|
|
2391
|
+
n(side.recorded),
|
|
2392
|
+
side.interval === null ? '—' : `[${pct(side.interval.low)}, ${pct(side.interval.high)}]`,
|
|
2393
|
+
)}`,
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
console.log();
|
|
2397
|
+
|
|
2398
|
+
if (result.separation === 'not-separable') {
|
|
2399
|
+
console.log(
|
|
2400
|
+
` ${c.dim('·')} ${wrap(
|
|
2401
|
+
t.experiment.notSeparable(
|
|
2402
|
+
result.notSeparable ?? '',
|
|
2403
|
+
result.outcomesNeededPerArm === null ? '—' : n(result.outcomesNeededPerArm),
|
|
2404
|
+
),
|
|
2405
|
+
74,
|
|
2406
|
+
' ',
|
|
2407
|
+
)}`,
|
|
2408
|
+
);
|
|
2409
|
+
} else {
|
|
2410
|
+
const winner = result.separation === 'a-wins' ? result.a.name : result.b.name;
|
|
2411
|
+
const d = result.difference as { low: number; high: number };
|
|
2412
|
+
// Reported as a magnitude: the sign is carried by which arm is named, and
|
|
2413
|
+
// printing "-30.0% to -18.0%" beside "b wins" is two ways of saying the
|
|
2414
|
+
// same thing that a reader has to reconcile.
|
|
2415
|
+
const lo = Math.min(Math.abs(d.low), Math.abs(d.high));
|
|
2416
|
+
const hi = Math.max(Math.abs(d.low), Math.abs(d.high));
|
|
2417
|
+
console.log(` ${c.green('✓')} ${wrap(t.experiment.wins(winner, pct(lo), pct(hi)), 74, ' ')}`);
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* The peek line, printed **whether or not** the arms separated.
|
|
2422
|
+
*
|
|
2423
|
+
* A separable result read too early is still separable and still read too
|
|
2424
|
+
* early. Collapsing the two would hide one of the facts, and it is always
|
|
2425
|
+
* the inconvenient one that goes.
|
|
2426
|
+
*/
|
|
2427
|
+
console.log();
|
|
2428
|
+
if (result.stopping.honoured) {
|
|
2429
|
+
console.log(` ${c.dim(wrap(t.experiment.honoured(n(result.stopping.declared)), 74, ' '))}`);
|
|
2430
|
+
} else {
|
|
2431
|
+
const short = result.stopping.short === result.a.name ? result.a : result.b;
|
|
2432
|
+
console.log(
|
|
2433
|
+
` ${c.yellow('!')} ${wrap(
|
|
2434
|
+
t.experiment.peeked(short.name, n(result.stopping.declared), n(short.recorded)),
|
|
2435
|
+
74,
|
|
2436
|
+
' ',
|
|
2437
|
+
)}`,
|
|
2438
|
+
);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
if (result.marginal !== null) {
|
|
2442
|
+
console.log();
|
|
2443
|
+
console.log(
|
|
2444
|
+
` ${wrap(
|
|
2445
|
+
result.marginal.usdPerExtraSuccess !== null
|
|
2446
|
+
? t.experiment.marginalDearer(
|
|
2447
|
+
result.marginal.better,
|
|
2448
|
+
formatUsd(result.marginal.usdPerExtraSuccess),
|
|
2449
|
+
)
|
|
2450
|
+
: t.experiment.marginalCheaper(result.marginal.better),
|
|
2451
|
+
74,
|
|
2452
|
+
' ',
|
|
2453
|
+
)}`,
|
|
2454
|
+
);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
console.log();
|
|
2458
|
+
console.log(` ${c.dim(wrap(t.experiment.neverPromotes(), 74, ' '))}`);
|
|
2459
|
+
console.log();
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2327
2462
|
function commandModels(t: CliMessages, pricing: PricingCatalogue): void {
|
|
2328
2463
|
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
2329
2464
|
const col = t.models.columns;
|
|
@@ -8524,6 +8659,9 @@ async function main(): Promise<void> {
|
|
|
8524
8659
|
case 'models':
|
|
8525
8660
|
commandModels(t, pricing);
|
|
8526
8661
|
break;
|
|
8662
|
+
case 'experiment':
|
|
8663
|
+
await commandExperiment(args, config, pricing, t);
|
|
8664
|
+
break;
|
|
8527
8665
|
case 'ladder':
|
|
8528
8666
|
await commandLadder(args, config, pricing, t);
|
|
8529
8667
|
break;
|