@eide/foir-cli 0.15.2 → 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.
- package/dist/cli.js +277 -15
- 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,14 +2097,17 @@ import {
|
|
|
2096
2097
|
ResumeExperimentRequestSchema,
|
|
2097
2098
|
EndExperimentRequestSchema,
|
|
2098
2099
|
GetExperimentStatsRequestSchema,
|
|
2100
|
+
ExperimentMetricSpecSchema,
|
|
2099
2101
|
ForceAssignExperimentRequestSchema,
|
|
2100
2102
|
RemoveExperimentAssignmentRequestSchema,
|
|
2101
2103
|
ApplyExperimentWinnerRequestSchema,
|
|
2102
2104
|
GetAssignmentsRequestSchema,
|
|
2103
2105
|
ListExperimentDeclarationsRequestSchema,
|
|
2104
2106
|
ExperimentVariantSchema,
|
|
2105
|
-
ExperimentGoalSchema
|
|
2107
|
+
ExperimentGoalSchema,
|
|
2108
|
+
ExperimentFunnelStepSchema
|
|
2106
2109
|
} from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2110
|
+
import { MetricAggregator as MetricAggregator2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2107
2111
|
import { ExperimentStatus as ExperimentStatus2 } from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
2108
2112
|
var STATUS_TO_PROTO = {
|
|
2109
2113
|
draft: ExperimentStatus.DRAFT,
|
|
@@ -2137,9 +2141,22 @@ function createExperimentsMethods(client) {
|
|
|
2137
2141
|
})
|
|
2138
2142
|
);
|
|
2139
2143
|
},
|
|
2140
|
-
async getExperimentStats(experimentId) {
|
|
2144
|
+
async getExperimentStats(experimentId, params = {}) {
|
|
2141
2145
|
const resp = await client.getExperimentStats(
|
|
2142
|
-
create7(GetExperimentStatsRequestSchema, {
|
|
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
|
+
})
|
|
2143
2160
|
);
|
|
2144
2161
|
return resp.stats ?? null;
|
|
2145
2162
|
},
|
|
@@ -2164,9 +2181,18 @@ function createExperimentsMethods(client) {
|
|
|
2164
2181
|
(g) => create7(ExperimentGoalSchema, {
|
|
2165
2182
|
key: g.key,
|
|
2166
2183
|
name: g.name,
|
|
2167
|
-
description: g.description
|
|
2184
|
+
description: g.description,
|
|
2185
|
+
isPrimary: g.isPrimary ?? false
|
|
2168
2186
|
})
|
|
2169
|
-
) ?? []
|
|
2187
|
+
) ?? [],
|
|
2188
|
+
funnelSteps: params.funnelSteps?.map(
|
|
2189
|
+
(s) => create7(ExperimentFunnelStepSchema, {
|
|
2190
|
+
goalKey: s.goalKey,
|
|
2191
|
+
name: s.name,
|
|
2192
|
+
description: s.description
|
|
2193
|
+
})
|
|
2194
|
+
) ?? [],
|
|
2195
|
+
exclusionGroupKey: params.exclusionGroupKey
|
|
2170
2196
|
})
|
|
2171
2197
|
);
|
|
2172
2198
|
return resp.experiment ?? null;
|
|
@@ -2191,10 +2217,21 @@ function createExperimentsMethods(client) {
|
|
|
2191
2217
|
(g) => create7(ExperimentGoalSchema, {
|
|
2192
2218
|
key: g.key,
|
|
2193
2219
|
name: g.name,
|
|
2194
|
-
description: g.description
|
|
2220
|
+
description: g.description,
|
|
2221
|
+
isPrimary: g.isPrimary ?? false
|
|
2222
|
+
})
|
|
2223
|
+
) ?? [],
|
|
2224
|
+
goalsClear: params.goalsClear ?? false,
|
|
2225
|
+
funnelSteps: params.funnelSteps?.map(
|
|
2226
|
+
(s) => create7(ExperimentFunnelStepSchema, {
|
|
2227
|
+
goalKey: s.goalKey,
|
|
2228
|
+
name: s.name,
|
|
2229
|
+
description: s.description
|
|
2195
2230
|
})
|
|
2196
2231
|
) ?? [],
|
|
2197
|
-
|
|
2232
|
+
funnelStepsClear: params.funnelStepsClear ?? false,
|
|
2233
|
+
exclusionGroupKey: params.exclusionGroupKey,
|
|
2234
|
+
exclusionGroupKeyClear: params.exclusionGroupKeyClear ?? false
|
|
2198
2235
|
})
|
|
2199
2236
|
);
|
|
2200
2237
|
return resp.experiment ?? null;
|
|
@@ -7219,7 +7256,8 @@ function registerSegmentsCommands(program2, globalOpts) {
|
|
|
7219
7256
|
import {
|
|
7220
7257
|
ExperimentSchema,
|
|
7221
7258
|
ExperimentStatsSchema,
|
|
7222
|
-
ExperimentAssignmentSchema
|
|
7259
|
+
ExperimentAssignmentSchema,
|
|
7260
|
+
MetricAggregator as MetricAggregator3
|
|
7223
7261
|
} from "@eide/foir-proto-ts/experiments/v1/experiments_pb";
|
|
7224
7262
|
function registerExperimentsCommands(program2, globalOpts) {
|
|
7225
7263
|
const experiments = program2.command("experiments").description("Manage experiments");
|
|
@@ -7359,13 +7397,37 @@ function registerExperimentsCommands(program2, globalOpts) {
|
|
|
7359
7397
|
success("Experiment ended");
|
|
7360
7398
|
})
|
|
7361
7399
|
);
|
|
7362
|
-
experiments.command("stats <id>").description(
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
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
|
+
)
|
|
7369
7431
|
);
|
|
7370
7432
|
experiments.command("force-assign <experimentId>").description(
|
|
7371
7433
|
"Force a known identity into a specific variant (admin override)."
|
|
@@ -7466,6 +7528,206 @@ function registerExperimentsCommands(program2, globalOpts) {
|
|
|
7466
7528
|
)
|
|
7467
7529
|
);
|
|
7468
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
|
+
}
|
|
7469
7731
|
|
|
7470
7732
|
// src/commands/schedules.ts
|
|
7471
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.
|
|
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.
|
|
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",
|