@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/index.js CHANGED
@@ -4,7 +4,7 @@ import { open, readdir, readFile, stat, writeFile } from 'node:fs/promises';
4
4
  import { dirname, join, resolve as resolvePath } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { gunzipSync } from 'node:zlib';
7
- import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, budgetPositions, conform, BREAK_EVEN_BAND, ladderPosition, validateLadder, outcomeReport, rankPerOutcome, FAILURE_POLICIES, detectFromSource, matchLocale, parsePlanDocument, waiverDay, waiverHistory, proposeInit, MIN_RATE_DAYS, parseConfig, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
7
+ import { applyRewrites, BASELINE_FILENAME, BASELINE_VERSION, breaches, cacheableMinimum, analyzeCachePrefix, billLevers, bucketedCacheEconomics, bucketedProfile, buildHistory, buildPlan, connectorFor, CONNECTORS, normalizeAnthropicUsage, normalizeOpenAIUsage, bucketsFromRecords, evaluateWatch, firedKey, pruneRecords, recordsFromBuckets, storeInventory, storedReportFrom, verifyPlan, cacheEconomics, cacheHitRate, contextPressure, comparePrompts, compareToBaseline, computeSavings, countTokensAnthropic, DEFAULT_USAGE, budgetPositions, conform, BREAK_EVEN_BAND, runExperiment, ladderPosition, validateLadder, outcomeReport, rankPerOutcome, FAILURE_POLICIES, detectFromSource, matchLocale, parsePlanDocument, waiverDay, waiverHistory, proposeInit, MIN_RATE_DAYS, parseConfig, coverageDrift, driversBetween, explainGateFailure, assignSources, fleetRollup, labelCoverage, measuredUsage, gateMargin, GATE_MARGIN_TIGHT, estimateTokens, evaluate, extractPrompts, findExamples, formatBaseline, formatSignedUsd, formatUsd, getMessages, getModel, hasMarker, LOCALES, MAX_BASELINE_BYTES, moneyIsComparable, mostSpecificMatch, nearestName, optimize, parseBaseline, PHRASE_LANGUAGES, plannedCalls, profilePrompt, profileToCsv, profileUsage, promptId, providerFromEnv, pruneExamples, refineWithLlm, rejectionText, reorderForCache, repriceProfile, reviewAgeDays, reviewExamples, RULES, sharedPrefixes, sharesOf, SOURCE_EXTENSIONS, suggestRewrites, toOtlpMetrics, toPromptfoo, TTL_1H_MS, UNLABELLED, withExactTokenCounts, } from '@trazum/core';
8
8
  import { cacheDir, cacheStats, cachingProvider, clearCache } from './suggest-cache.js';
9
9
  import { dayOf, formatGap, median, spanDays } from './time.js';
10
10
  // Everything that reads the filesystem, on its own entry point so the web
@@ -32,6 +32,9 @@ const c = {
32
32
  cyan: (s) => (useColor ? `\u001b[36m${s}\u001b[39m` : s),
33
33
  };
34
34
  const VALUE_FLAGS = new Set([
35
+ 'a',
36
+ 'b',
37
+ 'min-outcomes',
35
38
  'against',
36
39
  'contract',
37
40
  'on-cannot-tell',
@@ -320,6 +323,7 @@ const COMMAND_FLAGS = {
320
323
  feedback: [],
321
324
  gateway: ['on-cannot-tell', 'port', 'socket', 'pricing', 'pricing-live'],
322
325
  ladder: ['pricing', 'pricing-live', 'since', 'until', 'label'],
326
+ experiment: ['a', 'b', 'min-outcomes', 'pricing', 'pricing-live'],
323
327
  where: [],
324
328
  rules: [],
325
329
  blame: ['limit', 'model', 'calls', 'output-tokens', 'batch', 'prompt', 'markdown-out'],
@@ -1769,6 +1773,87 @@ async function commandLadder(args, config, pricing, t) {
1769
1773
  if (anyProblem)
1770
1774
  process.exitCode = 1;
1771
1775
  }
1776
+ /**
1777
+ * `trazum experiment <log> --a <label> --b <label> --min-outcomes <n>`
1778
+ *
1779
+ * Two arms on real traffic, judged on recorded outcomes and cost together.
1780
+ *
1781
+ * `--min-outcomes` is required and that is the entire point of it. A stopping
1782
+ * rule declared after looking at the numbers is not a stopping rule, and
1783
+ * nothing here can stop somebody reading a result early — what it can do is
1784
+ * make the early read **visible to whoever reads the result later**, which is
1785
+ * the part that survives the afternoon.
1786
+ */
1787
+ async function commandExperiment(args, config, pricing, t) {
1788
+ const path = args.positional[0];
1789
+ if (path === undefined)
1790
+ throw new Error(t.errors.missingInputFile());
1791
+ const aName = stringFlag(args, 'a');
1792
+ const bName = stringFlag(args, 'b');
1793
+ if (aName === undefined || bName === undefined)
1794
+ throw new Error(t.experiment.needsTwo());
1795
+ const minRaw = stringFlag(args, 'min-outcomes');
1796
+ const minOutcomesPerArm = minRaw === undefined ? Number.NaN : Number(minRaw);
1797
+ if (!Number.isInteger(minOutcomesPerArm) || minOutcomesPerArm < 1) {
1798
+ throw new Error(t.experiment.needsRule());
1799
+ }
1800
+ const report = profileUsage(await readUsageLog(path, t), { catalogue: pricing });
1801
+ const n = (value) => value.toLocaleString(t.numberLocale);
1802
+ const pct = (value) => `${(value * 100).toFixed(1)}%`;
1803
+ const armOf = (label) => {
1804
+ const slice = report.outcomeTallyByLabel.find((entry) => entry.label === label);
1805
+ return {
1806
+ name: label,
1807
+ totalUsd: slice?.totalUsd ?? 0,
1808
+ tally: slice?.tally ?? { byValue: [], recorded: 0, parsed: 0, unrecordedUsd: 0 },
1809
+ };
1810
+ };
1811
+ const result = runExperiment({ arms: [aName, bName], minOutcomesPerArm }, { a: armOf(aName), b: armOf(bName) }, config.outcomes ?? null);
1812
+ console.log();
1813
+ console.log(c.bold(t.experiment.heading(aName, bName)));
1814
+ console.log();
1815
+ for (const side of [result.a, result.b]) {
1816
+ console.log(` ${t.experiment.arm(side.name, side.rate === null ? '—' : pct(side.rate), n(side.successes), n(side.recorded), side.interval === null ? '—' : `[${pct(side.interval.low)}, ${pct(side.interval.high)}]`)}`);
1817
+ }
1818
+ console.log();
1819
+ if (result.separation === 'not-separable') {
1820
+ console.log(` ${c.dim('·')} ${wrap(t.experiment.notSeparable(result.notSeparable ?? '', result.outcomesNeededPerArm === null ? '—' : n(result.outcomesNeededPerArm)), 74, ' ')}`);
1821
+ }
1822
+ else {
1823
+ const winner = result.separation === 'a-wins' ? result.a.name : result.b.name;
1824
+ const d = result.difference;
1825
+ // Reported as a magnitude: the sign is carried by which arm is named, and
1826
+ // printing "-30.0% to -18.0%" beside "b wins" is two ways of saying the
1827
+ // same thing that a reader has to reconcile.
1828
+ const lo = Math.min(Math.abs(d.low), Math.abs(d.high));
1829
+ const hi = Math.max(Math.abs(d.low), Math.abs(d.high));
1830
+ console.log(` ${c.green('✓')} ${wrap(t.experiment.wins(winner, pct(lo), pct(hi)), 74, ' ')}`);
1831
+ }
1832
+ /**
1833
+ * The peek line, printed **whether or not** the arms separated.
1834
+ *
1835
+ * A separable result read too early is still separable and still read too
1836
+ * early. Collapsing the two would hide one of the facts, and it is always
1837
+ * the inconvenient one that goes.
1838
+ */
1839
+ console.log();
1840
+ if (result.stopping.honoured) {
1841
+ console.log(` ${c.dim(wrap(t.experiment.honoured(n(result.stopping.declared)), 74, ' '))}`);
1842
+ }
1843
+ else {
1844
+ const short = result.stopping.short === result.a.name ? result.a : result.b;
1845
+ console.log(` ${c.yellow('!')} ${wrap(t.experiment.peeked(short.name, n(result.stopping.declared), n(short.recorded)), 74, ' ')}`);
1846
+ }
1847
+ if (result.marginal !== null) {
1848
+ console.log();
1849
+ console.log(` ${wrap(result.marginal.usdPerExtraSuccess !== null
1850
+ ? t.experiment.marginalDearer(result.marginal.better, formatUsd(result.marginal.usdPerExtraSuccess))
1851
+ : t.experiment.marginalCheaper(result.marginal.better), 74, ' ')}`);
1852
+ }
1853
+ console.log();
1854
+ console.log(` ${c.dim(wrap(t.experiment.neverPromotes(), 74, ' '))}`);
1855
+ console.log();
1856
+ }
1772
1857
  function commandModels(t, pricing) {
1773
1858
  const n = (value) => value.toLocaleString(t.numberLocale);
1774
1859
  const col = t.models.columns;
@@ -6816,6 +6901,9 @@ async function main() {
6816
6901
  case 'models':
6817
6902
  commandModels(t, pricing);
6818
6903
  break;
6904
+ case 'experiment':
6905
+ await commandExperiment(args, config, pricing, t);
6906
+ break;
6819
6907
  case 'ladder':
6820
6908
  await commandLadder(args, config, pricing, t);
6821
6909
  break;