@eide/foir-cli 0.16.0 → 0.17.0

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.
Files changed (2) hide show
  1. package/dist/cli.js +251 -10
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -2083,6 +2083,7 @@ function createSegmentsMethods(client) {
2083
2083
 
2084
2084
  // src/lib/rpc/experiments.ts
2085
2085
  import { create as create7 } from "@bufbuild/protobuf";
2086
+ import { timestampFromDate } from "@bufbuild/protobuf/wkt";
2086
2087
  import {
2087
2088
  ExperimentStatus,
2088
2089
  CreateExperimentRequestSchema,
@@ -2096,6 +2097,7 @@ import {
2096
2097
  ResumeExperimentRequestSchema,
2097
2098
  EndExperimentRequestSchema,
2098
2099
  GetExperimentStatsRequestSchema,
2100
+ ExperimentMetricSpecSchema,
2099
2101
  ForceAssignExperimentRequestSchema,
2100
2102
  RemoveExperimentAssignmentRequestSchema,
2101
2103
  ApplyExperimentWinnerRequestSchema,
@@ -2105,6 +2107,7 @@ import {
2105
2107
  ExperimentGoalSchema,
2106
2108
  ExperimentFunnelStepSchema
2107
2109
  } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
2110
+ import { MetricAggregator as MetricAggregator2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
2108
2111
  import { ExperimentStatus as ExperimentStatus2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
2109
2112
  var STATUS_TO_PROTO = {
2110
2113
  draft: ExperimentStatus.DRAFT,
@@ -2138,9 +2141,22 @@ function createExperimentsMethods(client) {
2138
2141
  })
2139
2142
  );
2140
2143
  },
2141
- async getExperimentStats(experimentId) {
2144
+ async getExperimentStats(experimentId, params = {}) {
2142
2145
  const resp = await client.getExperimentStats(
2143
- create7(GetExperimentStatsRequestSchema, { experimentId })
2146
+ create7(GetExperimentStatsRequestSchema, {
2147
+ experimentId,
2148
+ from: params.from ? timestampFromDate(params.from) : void 0,
2149
+ to: params.to ? timestampFromDate(params.to) : void 0,
2150
+ metrics: params.metrics?.map(
2151
+ (m) => create7(ExperimentMetricSpecSchema, {
2152
+ name: m.name,
2153
+ path: m.path,
2154
+ aggregator: m.aggregator,
2155
+ goalKey: m.goalKey
2156
+ })
2157
+ ) ?? [],
2158
+ minSampleSize: params.minSampleSize
2159
+ })
2144
2160
  );
2145
2161
  return resp.stats ?? null;
2146
2162
  },
@@ -7240,7 +7256,8 @@ function registerSegmentsCommands(program2, globalOpts) {
7240
7256
  import {
7241
7257
  ExperimentSchema,
7242
7258
  ExperimentStatsSchema,
7243
- ExperimentAssignmentSchema
7259
+ ExperimentAssignmentSchema,
7260
+ MetricAggregator as MetricAggregator3
7244
7261
  } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
7245
7262
  function registerExperimentsCommands(program2, globalOpts) {
7246
7263
  const experiments = program2.command("experiments").description("Manage experiments");
@@ -7380,13 +7397,37 @@ function registerExperimentsCommands(program2, globalOpts) {
7380
7397
  success("Experiment ended");
7381
7398
  })
7382
7399
  );
7383
- experiments.command("stats <id>").description("Get experiment statistics").action(
7384
- withErrorHandler(globalOpts, async (id) => {
7385
- const opts = globalOpts();
7386
- const client = await createPlatformClient(opts);
7387
- const result = await client.experiments.getExperimentStats(id);
7388
- formatOutputProto(ExperimentStatsSchema, result, opts);
7389
- })
7400
+ experiments.command("stats <id>").description(
7401
+ "Decision-grade experiment statistics: per-variant assignments, per-goal conversion + significance, and optional metric aggregations."
7402
+ ).option("--from <iso8601>", "Lower bound on converted_at (defaults to experiment start)").option("--to <iso8601>", "Upper bound on converted_at (defaults to now)").option(
7403
+ "--metric <spec>",
7404
+ "Metric aggregation in name=path:aggregator[:goal_key] form (e.g. cart=cartValue:sum:checkout_complete). Repeatable.",
7405
+ collectMetric,
7406
+ []
7407
+ ).option("--min-sample-size <n>", "Per-arm sample-size gate for is_significant (default 100)", parseInt).action(
7408
+ withErrorHandler(
7409
+ globalOpts,
7410
+ async (id, cmdOpts) => {
7411
+ const opts = globalOpts();
7412
+ const client = await createPlatformClient(opts);
7413
+ const metrics = cmdOpts.metric.map(parseMetricSpec);
7414
+ const result = await client.experiments.getExperimentStats(id, {
7415
+ from: cmdOpts.from ? new Date(cmdOpts.from) : void 0,
7416
+ to: cmdOpts.to ? new Date(cmdOpts.to) : void 0,
7417
+ metrics,
7418
+ minSampleSize: cmdOpts.minSampleSize
7419
+ });
7420
+ if (opts.json || opts.jsonl) {
7421
+ formatOutputProto(ExperimentStatsSchema, result, opts);
7422
+ return;
7423
+ }
7424
+ if (!result) {
7425
+ console.log("No stats \u2014 experiment not found or no data in window.");
7426
+ return;
7427
+ }
7428
+ renderStatsTable(result);
7429
+ }
7430
+ )
7390
7431
  );
7391
7432
  experiments.command("force-assign <experimentId>").description(
7392
7433
  "Force a known identity into a specific variant (admin override)."
@@ -7487,6 +7528,206 @@ function registerExperimentsCommands(program2, globalOpts) {
7487
7528
  )
7488
7529
  );
7489
7530
  }
7531
+ function collectMetric(value, accumulator) {
7532
+ return [...accumulator, value];
7533
+ }
7534
+ function parseMetricSpec(raw) {
7535
+ const eq = raw.indexOf("=");
7536
+ if (eq <= 0) {
7537
+ throw new Error(
7538
+ `--metric ${JSON.stringify(raw)}: expected name=path:aggregator[:goal_key]`
7539
+ );
7540
+ }
7541
+ const name = raw.slice(0, eq);
7542
+ const rest = raw.slice(eq + 1);
7543
+ const parts = rest.split(":");
7544
+ if (parts.length < 2 || parts.length > 3) {
7545
+ throw new Error(
7546
+ `--metric ${JSON.stringify(raw)}: expected path:aggregator[:goal_key] after =`
7547
+ );
7548
+ }
7549
+ const path3 = parts[0];
7550
+ const aggStr = parts[1];
7551
+ const goalKey = parts[2];
7552
+ if (!path3 || !aggStr) {
7553
+ throw new Error(
7554
+ `--metric ${JSON.stringify(raw)}: expected path:aggregator[:goal_key] after =`
7555
+ );
7556
+ }
7557
+ return {
7558
+ name,
7559
+ path: path3,
7560
+ aggregator: parseAggregator(aggStr),
7561
+ goalKey: goalKey || void 0
7562
+ };
7563
+ }
7564
+ function parseAggregator(s) {
7565
+ switch (s.toLowerCase()) {
7566
+ case "sum":
7567
+ return MetricAggregator3.SUM;
7568
+ case "avg":
7569
+ case "mean":
7570
+ return MetricAggregator3.AVG;
7571
+ case "count":
7572
+ return MetricAggregator3.COUNT;
7573
+ case "min":
7574
+ return MetricAggregator3.MIN;
7575
+ case "max":
7576
+ return MetricAggregator3.MAX;
7577
+ case "p50":
7578
+ case "median":
7579
+ return MetricAggregator3.P50;
7580
+ case "p95":
7581
+ return MetricAggregator3.P95;
7582
+ }
7583
+ throw new Error(
7584
+ `unknown aggregator ${JSON.stringify(s)}: expected one of sum, avg, count, min, max, p50, p95`
7585
+ );
7586
+ }
7587
+ function renderStatsTable(s) {
7588
+ const winFrom = s.windowFrom?.seconds ? new Date(Number(s.windowFrom.seconds) * 1e3).toISOString().slice(0, 19) : "?";
7589
+ const winTo = s.windowTo?.seconds ? new Date(Number(s.windowTo.seconds) * 1e3).toISOString().slice(0, 19) : "?";
7590
+ console.log("");
7591
+ console.log(`Experiment ${s.experimentKey} (id ${s.experimentId})`);
7592
+ console.log(
7593
+ `Window ${winFrom} \u2192 ${winTo} grain: ${s.dailyGrain || "day"} total assignments: ${s.totalAssignments}`
7594
+ );
7595
+ console.log("");
7596
+ console.log("Variants");
7597
+ console.log(
7598
+ " " + padCols(["variant", "control", "assignments", "share"], [16, 8, 12, 8])
7599
+ );
7600
+ console.log(" " + "-".repeat(48));
7601
+ for (const v of s.variants) {
7602
+ const share = s.totalAssignments > 0 ? (v.assignmentCount / s.totalAssignments * 100).toFixed(1) + "%" : "-";
7603
+ console.log(
7604
+ " " + padCols(
7605
+ [v.variantKey, v.isControl ? "\u2713" : "", String(v.assignmentCount), share],
7606
+ [16, 8, 12, 8]
7607
+ )
7608
+ );
7609
+ }
7610
+ console.log("");
7611
+ const familySize = familySizeOf(s);
7612
+ for (const goal of s.goals) {
7613
+ console.log(`Goal: ${goal.goalKey}${goal.isPrimary ? " (primary)" : ""}`);
7614
+ console.log(
7615
+ " " + padCols(
7616
+ ["variant", "count", "unique", "rate", "lift", "p (raw)", "p (corr)", "sig"],
7617
+ [16, 8, 8, 9, 9, 10, 10, 5]
7618
+ )
7619
+ );
7620
+ console.log(" " + "-".repeat(75));
7621
+ for (const v of s.variants) {
7622
+ const cell = v.goalStats.find((g) => g.goalKey === goal.goalKey);
7623
+ if (!cell) continue;
7624
+ const rate = (cell.conversionRate * 100).toFixed(2) + "%";
7625
+ if (v.isControl) {
7626
+ console.log(
7627
+ " " + padCols(
7628
+ [v.variantKey, String(cell.conversionEventCount), String(cell.uniqueConverters), rate, "-", "-", "-", "-"],
7629
+ [16, 8, 8, 9, 9, 10, 10, 5]
7630
+ )
7631
+ );
7632
+ continue;
7633
+ }
7634
+ const sig = cell.significance;
7635
+ const rawP = sig?.pValue ?? 1;
7636
+ const corrP = Math.min(1, rawP * familySize);
7637
+ const lift = sig ? (sig.relativeLift >= 0 ? "+" : "") + (sig.relativeLift * 100).toFixed(1) + "%" : "-";
7638
+ const sigFlag = corrP < 0.05 && sig?.isSignificant ? "\u2713" : "";
7639
+ console.log(
7640
+ " " + padCols(
7641
+ [
7642
+ v.variantKey,
7643
+ String(cell.conversionEventCount),
7644
+ String(cell.uniqueConverters),
7645
+ rate,
7646
+ lift,
7647
+ rawP.toFixed(4),
7648
+ corrP.toFixed(4),
7649
+ sigFlag
7650
+ ],
7651
+ [16, 8, 8, 9, 9, 10, 10, 5]
7652
+ )
7653
+ );
7654
+ }
7655
+ console.log("");
7656
+ }
7657
+ if (s.metricStats.length > 0) {
7658
+ const byMetric = /* @__PURE__ */ new Map();
7659
+ for (const m of s.metricStats) {
7660
+ const list = byMetric.get(m.metricName) ?? [];
7661
+ list.push(m);
7662
+ byMetric.set(m.metricName, list);
7663
+ }
7664
+ for (const [name, rows] of byMetric) {
7665
+ console.log(`Metric: ${name}`);
7666
+ console.log(
7667
+ " " + padCols(
7668
+ ["variant", "value", "n", "skipped", "lift", "p", "sig"],
7669
+ [16, 12, 8, 8, 9, 8, 5]
7670
+ )
7671
+ );
7672
+ console.log(" " + "-".repeat(70));
7673
+ for (const m of rows) {
7674
+ const sig = m.significance;
7675
+ const lift = sig ? (sig.relativeLift >= 0 ? "+" : "") + (sig.relativeLift * 100).toFixed(1) + "%" : "-";
7676
+ const p = sig ? sig.pValue.toFixed(4) : "-";
7677
+ const sigFlag = sig?.isSignificant ? "\u2713" : "";
7678
+ console.log(
7679
+ " " + padCols(
7680
+ [
7681
+ m.variantKey,
7682
+ m.value.toFixed(2),
7683
+ String(m.sampleSize),
7684
+ String(m.skippedRowCount),
7685
+ lift,
7686
+ p,
7687
+ sigFlag
7688
+ ],
7689
+ [16, 12, 8, 8, 9, 8, 5]
7690
+ )
7691
+ );
7692
+ }
7693
+ console.log("");
7694
+ }
7695
+ }
7696
+ const winners = winnersOnPrimary(s, familySize);
7697
+ if (winners.length > 0) {
7698
+ console.log(`Significant on primary goal (Bonferroni-corrected): ${winners.join(", ")}`);
7699
+ } else {
7700
+ console.log("No variant has reached significance on the primary goal.");
7701
+ }
7702
+ }
7703
+ function familySizeOf(s) {
7704
+ const variantCount = s.variants.filter((v) => !v.isControl).length;
7705
+ const goalCount = s.goals.length;
7706
+ return Math.max(1, variantCount * goalCount);
7707
+ }
7708
+ function winnersOnPrimary(s, familySize) {
7709
+ const primary = s.goals.find((g) => g.isPrimary);
7710
+ if (!primary) return [];
7711
+ const out = [];
7712
+ for (const v of s.variants) {
7713
+ if (v.isControl) continue;
7714
+ const cell = v.goalStats.find((g) => g.goalKey === primary.goalKey);
7715
+ if (!cell?.significance) continue;
7716
+ const corrP = Math.min(1, cell.significance.pValue * familySize);
7717
+ if (corrP < 0.05 && cell.significance.isSignificant) {
7718
+ out.push(v.variantKey);
7719
+ }
7720
+ }
7721
+ return out;
7722
+ }
7723
+ function padCols(values, widths) {
7724
+ return values.map((v, i) => {
7725
+ const w = widths[i] ?? 12;
7726
+ const s = v ?? "";
7727
+ if (s.length >= w) return s.slice(0, w);
7728
+ return s + " ".repeat(w - s.length);
7729
+ }).join(" ");
7730
+ }
7490
7731
 
7491
7732
  // src/commands/schedules.ts
7492
7733
  import { CronScheduleSchema } from "@eide/foir-proto-ts/schedules/v1/schedules_pb";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eide/foir-cli",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Universal platform CLI for Foir platform",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -50,7 +50,7 @@
50
50
  "@bufbuild/protovalidate": "^1.1.1",
51
51
  "@connectrpc/connect": "^2.0.0",
52
52
  "@connectrpc/connect-node": "^2.0.0",
53
- "@eide/foir-proto-ts": "^0.44.0",
53
+ "@eide/foir-proto-ts": "^0.45.0",
54
54
  "chalk": "^5.3.0",
55
55
  "commander": "^12.1.0",
56
56
  "dotenv": "^16.4.5",