fix(stats): correct the trial/pricing experiment funnel report (#679)
## Summary Audited `stats:experiment-funnel` end-to-end (data acquisition, queries, per-row counting, financial math, and statistics) and fixed the issues that made it misleading for the win/no-win decision. The report used to rank variants by an **absolute** contribution-margin total that mechanically favoured whichever variant matured fastest, and it declared statistical significance with a normal approximation that is invalid at the small conversion counts this experiment has. ## What changed **Presentation & comparability** - Replace `A2P%` (numerator not a subset of the denominator → could exceed 100%) with `Conv%` = conversions ÷ matured-assigned — always ≤100% and comparable across variants. - Print `MatU` (matured cohort size) so the rate denominators are visible and the table reconciles; revive `ARPU`. - Reframe the guidance: compare on per-user `Conv%`/`ARPU`, not the absolute `MRR`/`Cost`/`Burn`/`CM` totals (which scale with `MatU`). **Data correctness** - Count soft-deleted users (`withTrashed`) — they were assigned a variant and their connections incurred real cost. - Attribute by the deterministic `SubscriptionExperiment::bucket()` instead of the resolved Pennant flag, so setting `force_variant` (the winner rollout switch) no longer collapses the whole report onto one variant. Also stops writing Pennant rows as a side effect. - Resolve `MRR` for subscriptions on rotated/archived Stripe price ids (fetch prices by product), warn on any net-active sub whose price is unmapped, round yearly ÷ 12, and skip foreign-currency prices. - `Burn` counts only users who never earned net revenue (no subscription, or paid-then-refunded) — a paid-then-churned user is no longer booked as connect-and-leave leak. **Statistics** - Measure conversion as "ever charged, net of refund" (time-invariant) instead of a live active-now snapshot that biases older cohorts (which have had longer to churn). - Decide significance with **Fisher's exact test** (exact at any sample size), Bonferroni-corrected over the three arms, instead of the normal-approx z that overstates evidence when expected cell counts fall below 5. Add a Newcombe difference-of-proportions CI and a small-sample caveat. - Extract the inference into `App\Services\Stats\ProportionSignificance` (+ a `BinomialProportion` value object), with unit tests that pin the exact interval and p-value numbers. ## Validation Reconstructed every column in raw SQL against a production dump — all reconcile **to the cent / to the row**. Independent recomputation of the statistics (Wilson, Fisher exact, Newcombe, z) matches the command's output. ## Testing 26 tests (20 feature + 6 unit, 94 assertions). `pint` and `phpstan`/larastan green. ## Note This branch also carries two small pre-existing commits unrelated to the funnel (`fix(categories): fall back to gray…`, `chore(schedule): stop scheduling the stuck cohort report`). Happy to split them into their own PR if preferred.
This commit is contained in:
parent
d7963736d1
commit
3db03de86d
|
|
@ -4,7 +4,9 @@ namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Features\SubscriptionExperiment;
|
use App\Features\SubscriptionExperiment;
|
||||||
use App\Services\Discord\DiscordWebhook;
|
use App\Services\Discord\DiscordWebhook;
|
||||||
|
use App\Services\Stats\BinomialProportion;
|
||||||
use App\Services\Stats\ExperimentFunnelCollector;
|
use App\Services\Stats\ExperimentFunnelCollector;
|
||||||
|
use App\Services\Stats\ProportionSignificance;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
|
@ -22,8 +24,10 @@ class SendExperimentFunnelReportCommand extends Command
|
||||||
SubscriptionExperiment::PAY_NOW => 'pay_now',
|
SubscriptionExperiment::PAY_NOW => 'pay_now',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(private ExperimentFunnelCollector $collector)
|
public function __construct(
|
||||||
{
|
private ExperimentFunnelCollector $collector,
|
||||||
|
private ProportionSignificance $significance,
|
||||||
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +46,10 @@ class SendExperimentFunnelReportCommand extends Command
|
||||||
$this->line($line);
|
$this->line($line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($this->significanceLines($report) as $line) {
|
||||||
|
$this->line($line);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->option('no-discord')) {
|
if ($this->option('no-discord')) {
|
||||||
$this->info('Skipped Discord (--no-discord).');
|
$this->info('Skipped Discord (--no-discord).');
|
||||||
|
|
||||||
|
|
@ -66,30 +74,103 @@ class SendExperimentFunnelReportCommand extends Command
|
||||||
{
|
{
|
||||||
$revenue = $report['revenueAvailable'];
|
$revenue = $report['revenueAvailable'];
|
||||||
$currency = $report['currency'];
|
$currency = $report['currency'];
|
||||||
$lines = [sprintf('%-8s %5s %5s %5s %5s %6s %8s %8s %8s %8s', 'Variant', 'Assg', 'Actd', 'Card', 'Paid', 'A2P%', 'MRR', 'Cost', 'Burn', 'CM')];
|
$lines = [sprintf(
|
||||||
|
'%-8s %5s %5s %5s %5s %5s %6s %7s %7s %7s %7s %7s',
|
||||||
|
'Variant', 'Assg', 'Actd', 'Card', 'MatU', 'Conv', 'Conv%', 'ARPU', 'MRR', 'Cost', 'Burn', 'CM',
|
||||||
|
)];
|
||||||
|
|
||||||
foreach (self::LABELS as $key => $label) {
|
foreach (self::LABELS as $key => $label) {
|
||||||
$row = $report['variants'][$key];
|
$row = $report['variants'][$key];
|
||||||
$mature = $row['activatedMature'] > 0;
|
$mature = $row['assignedMature'] > 0;
|
||||||
|
$showMoney = $revenue && $mature;
|
||||||
|
|
||||||
$lines[] = sprintf(
|
$lines[] = sprintf(
|
||||||
'%-8s %5d %5d %5d %5d %6s %8s %8s %8s %8s',
|
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
|
||||||
$label,
|
$label,
|
||||||
$row['assigned'],
|
$row['assigned'],
|
||||||
$row['activated'],
|
$row['activated'],
|
||||||
$row['subscribed'],
|
$row['subscribed'],
|
||||||
$row['activeMature'],
|
$row['assignedMature'],
|
||||||
$mature ? ((int) round($row['activationToPaidRate'] * 100)).'%' : 'pend',
|
$row['convertedMature'],
|
||||||
$revenue ? $this->money($row['mrrCents'], $currency) : '—',
|
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
|
||||||
|
$showMoney && $row['arpuCents'] !== null ? $this->money($row['arpuCents'], $currency) : '—',
|
||||||
|
$showMoney ? $this->money($row['mrrCents'], $currency) : '—',
|
||||||
$mature ? $this->money($row['costCents'], $currency) : '—',
|
$mature ? $this->money($row['costCents'], $currency) : '—',
|
||||||
$mature ? $this->money($row['wastedCostCents'], $currency) : '—',
|
$mature ? $this->money($row['wastedCostCents'], $currency) : '—',
|
||||||
$revenue && $mature ? $this->money($row['contributionMarginCents'], $currency) : '—',
|
$showMoney ? $this->money($row['contributionMarginCents'], $currency) : '—',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $lines;
|
return $lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-variant conversion-rate uncertainty (95% Wilson interval) plus the
|
||||||
|
* leader-vs-runner-up verdict from {@see ProportionSignificance} — a Fisher
|
||||||
|
* exact test and a Newcombe difference interval, Bonferroni-corrected — so
|
||||||
|
* "check significance before calling a winner" has the numbers behind it.
|
||||||
|
*
|
||||||
|
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function significanceLines(array $report): array
|
||||||
|
{
|
||||||
|
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
|
||||||
|
$arms = [];
|
||||||
|
|
||||||
|
foreach (self::LABELS as $key => $label) {
|
||||||
|
$row = $report['variants'][$key];
|
||||||
|
$n = (int) $row['assignedMature'];
|
||||||
|
$k = (int) $row['convertedMature'];
|
||||||
|
|
||||||
|
if ($n <= 0) {
|
||||||
|
$lines[] = sprintf(' %-8s pend (n=0)', $label);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$low, $high] = $this->significance->wilsonInterval($k, $n);
|
||||||
|
$lines[] = sprintf(' %-8s %6s [%6s – %6s] (n=%d)', $label, $this->percent($k / $n), $this->percent($low), $this->percent($high), $n);
|
||||||
|
$arms[] = new BinomialProportion($label, $k, $n);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($arms) < 2) {
|
||||||
|
$lines[] = 'Not enough matured variants to compare yet.';
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($arms, fn (BinomialProportion $a, BinomialProportion $b): int => $b->rate() <=> $a->rate());
|
||||||
|
[$leader, $runnerUp] = [$arms[0], $arms[1]];
|
||||||
|
$result = $this->significance->compare($leader, $runnerUp);
|
||||||
|
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'Leader %s vs %s: Δ %+.1f pts (95%% CI %+.1f … %+.1f pts, Newcombe).',
|
||||||
|
$leader->label, $runnerUp->label,
|
||||||
|
($leader->rate() - $runnerUp->rate()) * 100, $result['diffLow'] * 100, $result['diffHigh'] * 100,
|
||||||
|
);
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'Fisher exact p=%.3f %s α=%.3f (Bonferroni×3) -> %s.%s',
|
||||||
|
$result['fisherP'], $result['significant'] ? '<' : '≥', $result['alpha'],
|
||||||
|
$result['significant'] ? 'significant' : 'not significant',
|
||||||
|
$result['significant'] ? '' : ' Keep running.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result['minExpectedCount'] < 5.0) {
|
||||||
|
$lines[] = sprintf(
|
||||||
|
'(Small sample: min expected conversions %.1f < 5, so the normal-approx z=%.2f overstates — exact test used.)',
|
||||||
|
$result['minExpectedCount'], $result['z'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function percent(float $rate): string
|
||||||
|
{
|
||||||
|
return number_format($rate * 100, 1).'%';
|
||||||
|
}
|
||||||
|
|
||||||
private function money(int $cents, string $currency): string
|
private function money(int $cents, string $currency): string
|
||||||
{
|
{
|
||||||
$symbol = match (strtolower($currency)) {
|
$symbol = match (strtolower($currency)) {
|
||||||
|
|
@ -118,17 +199,22 @@ class SendExperimentFunnelReportCommand extends Command
|
||||||
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
|
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
|
||||||
'inline' => false,
|
'inline' => false,
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'name' => '📊 Significance',
|
||||||
|
'value' => "```\n".implode("\n", $this->significanceLines($report))."\n```",
|
||||||
|
'inline' => false,
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'name' => 'Legend',
|
'name' => 'Legend',
|
||||||
'value' => sprintf(
|
'value' => sprintf(
|
||||||
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · Paid = live non-refunded subs (mature) · A2P%% = Paid ÷ activated (mature) · MRR = monthly run-rate of paid subs (yearly ÷ 12) · Cost = est. connection cost of the mature cohort (%s/connection) · Burn = connection cost of mature users who never converted (money lost) · CM = MRR − Cost · `pend`/`—` = no mature data yet.',
|
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR − Cost · `pend`/`—` = no matured data yet.',
|
||||||
$this->money($report['costPerConnectionCents'], $report['currency']),
|
$this->money($report['costPerConnectionCents'], $report['currency']),
|
||||||
),
|
),
|
||||||
'inline' => false,
|
'inline' => false,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'name' => '⚠️ How to read it',
|
'name' => '⚠️ How to read it',
|
||||||
'value' => 'Each variant is gated by its own decision window (control 15d, reduced 7d, pay_now 3d), so pay_now matures first — compare only once all three have mature volume, and check significance before calling a winner. **CM (contribution margin) is the decision metric.** Assg/Actd/Card are lifetime counts; A2P%/Cost/Burn/CM cover only the mature cohort, so the raw Actd→Card→Paid funnel mixes cohorts (immature carded users can\'t be Paid yet) — read it for volume, compare variants on A2P%/CM. Burn is what the connect-and-leave leak costs. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
|
'value' => 'Each variant matures on its own decision window (control 15d, reduced 7d, pay_now 3d, +3d settle), so at any moment MatU differs a lot between variants (pay_now matures first). **Compare variants on Conv% and ARPU — normalized per matured user — not on the absolute MRR/Cost/Burn/CM totals, which scale with MatU and so mechanically favour whichever variant has matured more.** Assg/Actd/Card are lifetime counts; everything from MatU rightward covers the matured cohort only, so the raw Actd→Card→Conv funnel mixes cohorts (immature carded users can\'t have matured yet) — read it for volume. Conv counts anyone ever charged (net of refund), so it is not depressed for older cohorts the way a live-active snapshot would be. Per-user CM is sub-cent at current volume, so treat CM as directional context, not the decision. Check significance (sample size = MatU) before calling a winner. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
|
||||||
'inline' => false,
|
'inline' => false,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Stats;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arm of a binomial experiment: a count of successes out of trials, with a
|
||||||
|
* label. Bundles the (successes, trials) pair that the significance methods
|
||||||
|
* would otherwise pass around as loose integers.
|
||||||
|
*/
|
||||||
|
final readonly class BinomialProportion
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $label,
|
||||||
|
public int $successes,
|
||||||
|
public int $trials,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function rate(): float
|
||||||
|
{
|
||||||
|
return $this->trials > 0 ? (float) $this->successes / $this->trials : 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,9 @@ use App\Features\SubscriptionExperiment;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Laravel\Cashier\Cashier;
|
use Laravel\Cashier\Cashier;
|
||||||
use Laravel\Cashier\Subscription;
|
use Laravel\Cashier\Subscription;
|
||||||
use Laravel\Pennant\Feature;
|
|
||||||
|
|
||||||
class ExperimentFunnelCollector
|
class ExperimentFunnelCollector
|
||||||
{
|
{
|
||||||
|
|
@ -20,12 +20,13 @@ class ExperimentFunnelCollector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-variant funnel for the trial/pricing experiment. Users are attributed
|
* Per-variant funnel for the trial/pricing experiment. Users are attributed
|
||||||
* by the variant Pennant resolved for them — the same value the runtime
|
* by SubscriptionExperiment::bucket() — the deterministic crc32 split that is
|
||||||
* served at checkout/paywall — so the report can't drift from what users
|
* the single source of truth for assignment — over the in-window signups the
|
||||||
* actually experienced (including any QA override or a legacy bucket that
|
* query selects, so it matches the variant each user was served without being
|
||||||
* predates the experiment). "Net active" is a live, non-refunded
|
* perturbed by the force_variant rollout hook (which pins every user to the
|
||||||
* subscription — an exact, heuristic-free metric that is comparable across
|
* winner once decided) or by Pennant store drift. "Net active" is a live,
|
||||||
* variants once each cohort clears its own decision window.
|
* non-refunded subscription — an exact, heuristic-free metric that is
|
||||||
|
* comparable across variants once each cohort clears its own decision window.
|
||||||
*
|
*
|
||||||
* The funnel is assigned → activated → carded (subscribed) → net-paying:
|
* The funnel is assigned → activated → carded (subscribed) → net-paying:
|
||||||
* "activated" = the user connected a bank or enabled AI, i.e. triggered the
|
* "activated" = the user connected a bank or enabled AI, i.e. triggered the
|
||||||
|
|
@ -61,7 +62,9 @@ class ExperimentFunnelCollector
|
||||||
* refunded: int,
|
* refunded: int,
|
||||||
* assignedMature: int,
|
* assignedMature: int,
|
||||||
* activatedMature: int,
|
* activatedMature: int,
|
||||||
|
* convertedMature: int,
|
||||||
* activeMature: int,
|
* activeMature: int,
|
||||||
|
* conversionRate: ?float,
|
||||||
* netActiveRate: ?float,
|
* netActiveRate: ?float,
|
||||||
* activationToPaidRate: ?float,
|
* activationToPaidRate: ?float,
|
||||||
* mrrCents: int,
|
* mrrCents: int,
|
||||||
|
|
@ -92,8 +95,13 @@ class ExperimentFunnelCollector
|
||||||
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
|
$excluded = (array) config('ai_suggestions.report.excluded_emails', []);
|
||||||
$windows = $this->decisionWindows();
|
$windows = $this->decisionWindows();
|
||||||
$monthlyEquiv = $this->monthlyEquivByPriceId();
|
$monthlyEquiv = $this->monthlyEquivByPriceId();
|
||||||
|
$missingPrices = [];
|
||||||
|
|
||||||
User::query()
|
User::query()
|
||||||
|
// Soft-deleted accounts still count: they were assigned a variant and
|
||||||
|
// their bank connections incurred real cost, and deleting the account
|
||||||
|
// is itself an experiment outcome (the strongest "connect and leave").
|
||||||
|
->withTrashed()
|
||||||
->where('users.created_at', '>=', $startedAt)
|
->where('users.created_at', '>=', $startedAt)
|
||||||
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
|
->when($excluded !== [], fn ($query) => $query->whereNotIn('email', $excluded))
|
||||||
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
|
->with(['subscriptions' => fn ($query) => $query->where('type', 'default')])
|
||||||
|
|
@ -104,11 +112,16 @@ class ExperimentFunnelCollector
|
||||||
'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(),
|
'bankingConnections as connection_count' => fn ($query) => $query->withTrashed(),
|
||||||
'aiConsents as ai_consent_count',
|
'aiConsents as ai_consent_count',
|
||||||
])
|
])
|
||||||
->chunkById(500, function ($users) use (&$variants, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void {
|
->chunkById(500, function ($users) use (&$variants, &$missingPrices, $windows, $now, $monthlyEquiv, $costPerConnectionCents): void {
|
||||||
Feature::for($users)->load([SubscriptionExperiment::class]);
|
|
||||||
|
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
$variant = Feature::for($user)->value(SubscriptionExperiment::class);
|
// Attribute by the deterministic bucket (the single source of
|
||||||
|
// truth in SubscriptionExperiment), not the resolved Pennant
|
||||||
|
// value: the latter is short-circuited by the force_variant
|
||||||
|
// rollout hook, which would collapse every user onto one
|
||||||
|
// variant once a winner is pinned. Every queried user is
|
||||||
|
// in-window, so bucket() equals the variant they were served,
|
||||||
|
// and reading it avoids writing Pennant rows as a side effect.
|
||||||
|
$variant = SubscriptionExperiment::bucket((string) $user->id);
|
||||||
|
|
||||||
if (! isset($variants[$variant])) {
|
if (! isset($variants[$variant])) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -130,6 +143,21 @@ class ExperimentFunnelCollector
|
||||||
$status = $subscription?->stripe_status;
|
$status = $subscription?->stripe_status;
|
||||||
$netActive = $status === 'active' && $subscription->refunded_at === null;
|
$netActive = $status === 'active' && $subscription->refunded_at === null;
|
||||||
|
|
||||||
|
// "Converted" is time-invariant: the user was ever charged and
|
||||||
|
// not refunded — currently active, or churned after the trial.
|
||||||
|
// Unlike $netActive (a live snapshot), it does not shrink as an
|
||||||
|
// older cohort has more time to cancel, so it is comparable
|
||||||
|
// across variants that matured at different times. Excludes
|
||||||
|
// trial-only cancels (ended on/before the trial → never charged).
|
||||||
|
$converted = $subscription !== null
|
||||||
|
&& $subscription->refunded_at === null
|
||||||
|
&& $status !== 'trialing'
|
||||||
|
&& (
|
||||||
|
$subscription->trial_ends_at === null
|
||||||
|
|| $subscription->ends_at === null
|
||||||
|
|| $subscription->ends_at->greaterThan($subscription->trial_ends_at)
|
||||||
|
);
|
||||||
|
|
||||||
if ($subscription !== null) {
|
if ($subscription !== null) {
|
||||||
$row['subscribed']++;
|
$row['subscribed']++;
|
||||||
$row['trialing'] += $status === 'trialing' ? 1 : 0;
|
$row['trialing'] += $status === 'trialing' ? 1 : 0;
|
||||||
|
|
@ -154,10 +182,25 @@ class ExperimentFunnelCollector
|
||||||
$row['activatedMature']++;
|
$row['activatedMature']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($converted) {
|
||||||
|
$row['convertedMature']++;
|
||||||
|
}
|
||||||
|
|
||||||
if ($netActive) {
|
if ($netActive) {
|
||||||
$row['activeMature']++;
|
$row['activeMature']++;
|
||||||
$row['mrrCents'] += (int) ($monthlyEquiv[$subscription->stripe_price] ?? 0);
|
$priceId = (string) $subscription->stripe_price;
|
||||||
} else {
|
|
||||||
|
if ($monthlyEquiv !== [] && ! isset($monthlyEquiv[$priceId])) {
|
||||||
|
$missingPrices[$priceId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['mrrCents'] += (int) ($monthlyEquiv[$priceId] ?? 0);
|
||||||
|
} elseif ($subscription === null || $subscription->refunded_at !== null) {
|
||||||
|
// Burn = connections of matured users who never earned
|
||||||
|
// net revenue: connected a bank but never carded, or
|
||||||
|
// paid and got refunded. A user who paid and later
|
||||||
|
// churned (canceled, not refunded) did convert, so
|
||||||
|
// their connection cost is not burn.
|
||||||
$row['wastedCostCents'] += $connectionCostCents;
|
$row['wastedCostCents'] += $connectionCostCents;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,12 +209,21 @@ class ExperimentFunnelCollector
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ($missingPrices !== []) {
|
||||||
|
Log::warning('Experiment funnel: net-active subscriptions on prices absent from the monthly-equivalent map — their MRR is undercounted as 0.', [
|
||||||
|
'price_ids' => array_keys($missingPrices),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($variants as $key => $row) {
|
foreach ($variants as $key => $row) {
|
||||||
|
$variants[$key]['conversionRate'] = $row['assignedMature'] > 0
|
||||||
|
? (float) $row['convertedMature'] / $row['assignedMature']
|
||||||
|
: null;
|
||||||
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
|
$variants[$key]['netActiveRate'] = $row['assignedMature'] > 0
|
||||||
? $row['activeMature'] / $row['assignedMature']
|
? (float) $row['activeMature'] / $row['assignedMature']
|
||||||
: null;
|
: null;
|
||||||
$variants[$key]['activationToPaidRate'] = $row['activatedMature'] > 0
|
$variants[$key]['activationToPaidRate'] = $row['activatedMature'] > 0
|
||||||
? $row['activeMature'] / $row['activatedMature']
|
? (float) $row['activeMature'] / $row['activatedMature']
|
||||||
: null;
|
: null;
|
||||||
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
|
$variants[$key]['arpuCents'] = $row['assignedMature'] > 0
|
||||||
? (int) round($row['mrrCents'] / $row['assignedMature'])
|
? (int) round($row['mrrCents'] / $row['assignedMature'])
|
||||||
|
|
@ -189,7 +241,7 @@ class ExperimentFunnelCollector
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{assigned: int, activated: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activatedMature: int, activeMature: int, netActiveRate: ?float, activationToPaidRate: ?float, mrrCents: int, arpuCents: ?int, costCents: int, wastedCostCents: int, contributionMarginCents: int}
|
* @return array{assigned: int, activated: int, subscribed: int, trialing: int, trialingCanceling: int, active: int, canceled: int, pastDue: int, refunded: int, assignedMature: int, activatedMature: int, convertedMature: int, activeMature: int, conversionRate: ?float, netActiveRate: ?float, activationToPaidRate: ?float, mrrCents: int, arpuCents: ?int, costCents: int, wastedCostCents: int, contributionMarginCents: int}
|
||||||
*/
|
*/
|
||||||
private function emptyRow(): array
|
private function emptyRow(): array
|
||||||
{
|
{
|
||||||
|
|
@ -205,7 +257,9 @@ class ExperimentFunnelCollector
|
||||||
'refunded' => 0,
|
'refunded' => 0,
|
||||||
'assignedMature' => 0,
|
'assignedMature' => 0,
|
||||||
'activatedMature' => 0,
|
'activatedMature' => 0,
|
||||||
|
'convertedMature' => 0,
|
||||||
'activeMature' => 0,
|
'activeMature' => 0,
|
||||||
|
'conversionRate' => null,
|
||||||
'netActiveRate' => null,
|
'netActiveRate' => null,
|
||||||
'activationToPaidRate' => null,
|
'activationToPaidRate' => null,
|
||||||
'mrrCents' => 0,
|
'mrrCents' => 0,
|
||||||
|
|
@ -218,8 +272,13 @@ class ExperimentFunnelCollector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monthly-equivalent amount (in cents) for each plan price id, from Stripe.
|
* Monthly-equivalent amount (in cents) for each plan price id, from Stripe.
|
||||||
* Yearly prices are divided by 12. Cached for an hour; returns [] (revenue
|
* Yearly prices are divided by 12. Fetched by product so that archived,
|
||||||
* unavailable) if Stripe can't be reached, without caching the failure.
|
* rotated price ids (Stripe mints a new id and transfers the lookup key on
|
||||||
|
* any amount change) still resolve — otherwise subscriptions on an old id
|
||||||
|
* would silently contribute 0 to MRR. Falls back to the current lookup keys
|
||||||
|
* when no product is configured. Foreign-currency and one-off prices are
|
||||||
|
* skipped. Cached for an hour; returns [] (revenue unavailable) if Stripe
|
||||||
|
* can't be reached, without caching the failure.
|
||||||
*
|
*
|
||||||
* @return array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
|
|
@ -231,26 +290,40 @@ class ExperimentFunnelCollector
|
||||||
return Cache::get($key);
|
return Cache::get($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$productId = config('subscriptions.products.pro');
|
||||||
$lookups = array_values(array_filter([
|
$lookups = array_values(array_filter([
|
||||||
config('subscriptions.plans.monthly.stripe_lookup_key'),
|
config('subscriptions.plans.monthly.stripe_lookup_key'),
|
||||||
config('subscriptions.plans.yearly.stripe_lookup_key'),
|
config('subscriptions.plans.yearly.stripe_lookup_key'),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if ($lookups === []) {
|
if ($productId === null && $lookups === []) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$params = $productId !== null
|
||||||
|
? ['product' => $productId, 'limit' => 100]
|
||||||
|
: ['lookup_keys' => $lookups, 'limit' => 10];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$prices = Cashier::stripe()->prices->all(['lookup_keys' => $lookups, 'limit' => 10]);
|
$prices = Cashier::stripe()->prices->all($params);
|
||||||
} catch (\Throwable) {
|
} catch (\Throwable) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$currency = strtolower((string) config('cashier.currency', 'eur'));
|
||||||
$map = [];
|
$map = [];
|
||||||
foreach ($prices->data as $price) {
|
foreach ($prices->data as $price) {
|
||||||
|
if ($price->recurring === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower((string) ($price->currency ?? $currency)) !== $currency) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$amount = (int) ($price->unit_amount ?? 0);
|
$amount = (int) ($price->unit_amount ?? 0);
|
||||||
$map[$price->id] = ($price->recurring->interval ?? 'month') === 'year'
|
$map[$price->id] = ($price->recurring->interval ?? 'month') === 'year'
|
||||||
? intdiv($amount, 12)
|
? (int) round($amount / 12)
|
||||||
: $amount;
|
: $amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Stats;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frequentist inference on binomial proportions for the experiment funnel:
|
||||||
|
* per-arm Wilson intervals, a Newcombe interval for the difference of two
|
||||||
|
* proportions, and a Fisher exact test for the leader-vs-runner-up comparison.
|
||||||
|
*
|
||||||
|
* Fisher (exact at any sample size) is the decision test because the report's
|
||||||
|
* conversion counts are too small for the two-proportion z normal approximation
|
||||||
|
* to be valid — its expected cell counts fall well below the np>=5 rule.
|
||||||
|
*/
|
||||||
|
final class ProportionSignificance
|
||||||
|
{
|
||||||
|
/** Two-sided 95% standard-normal quantile. */
|
||||||
|
private const Z_95 = 1.96;
|
||||||
|
|
||||||
|
/** @var list<float> memoised log-factorials, index i = log(i!) */
|
||||||
|
private array $logFactorials = [0.0, 0.0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare the two leading arms: Newcombe difference interval, Fisher exact
|
||||||
|
* p-value, and a family-wise (Bonferroni) corrected significance flag. `z`
|
||||||
|
* and `minExpectedCount` are returned for the small-sample caveat only.
|
||||||
|
*
|
||||||
|
* @return array{alpha: float, diffLow: float, diffHigh: float, fisherP: float, significant: bool, minExpectedCount: float, z: float}
|
||||||
|
*/
|
||||||
|
public function compare(BinomialProportion $leader, BinomialProportion $runnerUp, float $familyAlpha = 0.05, int $comparisons = 3): array
|
||||||
|
{
|
||||||
|
$alpha = $familyAlpha / $comparisons;
|
||||||
|
[$diffLow, $diffHigh] = $this->newcombeDiffInterval($leader, $runnerUp);
|
||||||
|
$fisherP = $this->fisherExactTwoSided(
|
||||||
|
$leader->successes, $leader->trials - $leader->successes,
|
||||||
|
$runnerUp->successes, $runnerUp->trials - $runnerUp->successes,
|
||||||
|
);
|
||||||
|
|
||||||
|
$pooled = ($leader->successes + $runnerUp->successes) / ($leader->trials + $runnerUp->trials);
|
||||||
|
$minExpectedCount = min($leader->trials, $runnerUp->trials) * min($pooled, 1 - $pooled);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'alpha' => $alpha,
|
||||||
|
'diffLow' => $diffLow,
|
||||||
|
'diffHigh' => $diffHigh,
|
||||||
|
'fisherP' => $fisherP,
|
||||||
|
'significant' => $fisherP < $alpha,
|
||||||
|
'minExpectedCount' => $minExpectedCount,
|
||||||
|
'z' => $this->twoProportionZ($leader, $runnerUp),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wilson score interval for a binomial proportion — accurate for small n
|
||||||
|
* and near 0/1, where the normal approximation misbehaves.
|
||||||
|
*
|
||||||
|
* @return array{0: float, 1: float} lower and upper bound, clamped to [0, 1]
|
||||||
|
*/
|
||||||
|
public function wilsonInterval(int $successes, int $trials, float $z = self::Z_95): array
|
||||||
|
{
|
||||||
|
$p = $successes / $trials;
|
||||||
|
$z2 = $z * $z;
|
||||||
|
$denom = 1 + $z2 / $trials;
|
||||||
|
$center = ($p + $z2 / (2 * $trials)) / $denom;
|
||||||
|
$margin = ($z / $denom) * sqrt($p * (1 - $p) / $trials + $z2 / (4 * $trials * $trials));
|
||||||
|
|
||||||
|
return [max(0.0, $center - $margin), min(1.0, $center + $margin)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Newcombe (Wilson-based) 95% interval for the difference pA − pB. The
|
||||||
|
* correct object for "is A better than B": overlapping marginal intervals do
|
||||||
|
* NOT imply the difference includes 0.
|
||||||
|
*
|
||||||
|
* @return array{0: float, 1: float}
|
||||||
|
*/
|
||||||
|
public function newcombeDiffInterval(BinomialProportion $a, BinomialProportion $b, float $z = self::Z_95): array
|
||||||
|
{
|
||||||
|
$pA = $a->rate();
|
||||||
|
$pB = $b->rate();
|
||||||
|
[$lA, $uA] = $this->wilsonInterval($a->successes, $a->trials, $z);
|
||||||
|
[$lB, $uB] = $this->wilsonInterval($b->successes, $b->trials, $z);
|
||||||
|
|
||||||
|
$lower = ($pA - $pB) - sqrt(($pA - $lA) ** 2 + ($uB - $pB) ** 2);
|
||||||
|
$upper = ($pA - $pB) + sqrt(($uA - $pA) ** 2 + ($pB - $lB) ** 2);
|
||||||
|
|
||||||
|
return [$lower, $upper];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pooled two-proportion z statistic — descriptive only, not the decision test. */
|
||||||
|
public function twoProportionZ(BinomialProportion $a, BinomialProportion $b): float
|
||||||
|
{
|
||||||
|
$pooled = ($a->successes + $b->successes) / ($a->trials + $b->trials);
|
||||||
|
$se = sqrt($pooled * (1 - $pooled) * (1 / $a->trials + 1 / $b->trials));
|
||||||
|
|
||||||
|
return $se > 0.0 ? ($a->rate() - $b->rate()) / $se : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-sided Fisher exact p-value for the 2x2 table [[a, b], [c, d]]
|
||||||
|
* (a/c = successes, b/d = failures). Sums the hypergeometric probabilities
|
||||||
|
* of every same-margin table no more likely than the observed one. Exact at
|
||||||
|
* any sample size — no normal approximation.
|
||||||
|
*/
|
||||||
|
public function fisherExactTwoSided(int $a, int $b, int $c, int $d): float
|
||||||
|
{
|
||||||
|
$rowA = $a + $b;
|
||||||
|
$rowB = $c + $d;
|
||||||
|
$col = $a + $c;
|
||||||
|
$total = $rowA + $rowB;
|
||||||
|
|
||||||
|
if ($rowA === 0 || $rowB === 0 || $col === 0 || $col === $total) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$logProbObserved = $this->hypergeometricLogProb($a, $rowA, $rowB, $col);
|
||||||
|
$p = 0.0;
|
||||||
|
for ($x = max(0, $col - $rowB); $x <= min($col, $rowA); $x++) {
|
||||||
|
$logProb = $this->hypergeometricLogProb($x, $rowA, $rowB, $col);
|
||||||
|
if ($logProb <= $logProbObserved + 1e-7) {
|
||||||
|
$p += exp($logProb);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return min(1.0, $p);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hypergeometricLogProb(int $x, int $rowA, int $rowB, int $col): float
|
||||||
|
{
|
||||||
|
return $this->logChoose($rowA, $x) + $this->logChoose($rowB, $col - $x) - $this->logChoose($rowA + $rowB, $col);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logChoose(int $n, int $k): float
|
||||||
|
{
|
||||||
|
if ($k < 0 || $k > $n) {
|
||||||
|
return -INF;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->logFactorial($n) - $this->logFactorial($k) - $this->logFactorial($n - $k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logFactorial(int $n): float
|
||||||
|
{
|
||||||
|
for ($i = count($this->logFactorials); $i <= $n; $i++) {
|
||||||
|
$this->logFactorials[$i] = $this->logFactorials[$i - 1] + log($i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->logFactorials[$n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,8 +7,10 @@ use App\Models\User;
|
||||||
use App\Services\Stats\ExperimentFunnelCollector;
|
use App\Services\Stats\ExperimentFunnelCollector;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
use function Pest\Laravel\artisan;
|
use function Pest\Laravel\artisan;
|
||||||
|
|
@ -34,7 +36,7 @@ beforeEach(function () {
|
||||||
* with an optional default subscription and any bank connections / AI consent
|
* with an optional default subscription and any bank connections / AI consent
|
||||||
* that mark the user as "activated" and drive the connection-cost columns.
|
* that mark the user as "activated" and drive the connection-cost columns.
|
||||||
*
|
*
|
||||||
* @param array{status: string, at: CarbonImmutable, endsAt?: CarbonImmutable, refundedAt?: CarbonImmutable}|null $subscription
|
* @param array{status: string, at: CarbonImmutable, endsAt?: CarbonImmutable, trialEndsAt?: CarbonImmutable, refundedAt?: CarbonImmutable}|null $subscription
|
||||||
*/
|
*/
|
||||||
function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscription = null, int $connections = 0, bool $aiConsent = false): User
|
function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscription = null, int $connections = 0, bool $aiConsent = false): User
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +54,7 @@ function experimentUser(string $variant, CarbonImmutable $signup, ?array $subscr
|
||||||
'stripe_price' => 'price_test',
|
'stripe_price' => 'price_test',
|
||||||
'created_at' => $subscription['at'],
|
'created_at' => $subscription['at'],
|
||||||
'ends_at' => $subscription['endsAt'] ?? null,
|
'ends_at' => $subscription['endsAt'] ?? null,
|
||||||
|
'trial_ends_at' => $subscription['trialEndsAt'] ?? null,
|
||||||
'refunded_at' => $subscription['refundedAt'] ?? null,
|
'refunded_at' => $subscription['refundedAt'] ?? null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +176,162 @@ it('computes connection cost, wasted burn and contribution margin', function ()
|
||||||
->and($control['activationToPaidRate'])->toBe(0.5); // 1 paid ÷ 2 activated
|
->and($control['activationToPaidRate'])->toBe(0.5); // 1 paid ÷ 2 activated
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the A/B/C split even when a winner is forced, instead of collapsing onto one variant', function () {
|
||||||
|
// force_variant pins the runtime to one variant; the report must still show
|
||||||
|
// the real historical split, not attribute everyone to the forced winner.
|
||||||
|
config(['subscriptions.experiment.force_variant' => SubscriptionExperiment::PAY_NOW]);
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup);
|
||||||
|
experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup);
|
||||||
|
experimentUser(SubscriptionExperiment::PAY_NOW, $signup);
|
||||||
|
|
||||||
|
$variants = app(ExperimentFunnelCollector::class)->collect()['variants'];
|
||||||
|
|
||||||
|
expect($variants[SubscriptionExperiment::CONTROL]['assigned'])->toBe(1)
|
||||||
|
->and($variants[SubscriptionExperiment::REDUCED_TRIAL]['assigned'])->toBe(1)
|
||||||
|
->and($variants[SubscriptionExperiment::PAY_NOW]['assigned'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns instead of silently zeroing MRR when a paid sub is on an unmapped (rotated) price', function () {
|
||||||
|
Log::spy();
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
// The seeded map only knows 'price_test'; move this paid sub onto a rotated id.
|
||||||
|
$user = experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup, ['status' => 'active', 'at' => $signup]);
|
||||||
|
$user->subscriptions()->first()->update(['stripe_price' => 'price_rotated_old']);
|
||||||
|
|
||||||
|
$reduced = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::REDUCED_TRIAL];
|
||||||
|
|
||||||
|
expect($reduced['activeMature'])->toBe(1)
|
||||||
|
->and($reduced['mrrCents'])->toBe(0); // unmapped price → 0, but now loudly
|
||||||
|
|
||||||
|
Log::shouldHaveReceived('warning')
|
||||||
|
->withArgs(fn (string $message, array $context = []) => str_contains($message, 'absent from the monthly-equivalent map')
|
||||||
|
&& in_array('price_rotated_old', $context['price_ids'] ?? [], true))
|
||||||
|
->once();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still counts soft-deleted users so their assignment and connection cost survive', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
$user = experimentUser(SubscriptionExperiment::CONTROL, $signup, null, connections: 2);
|
||||||
|
$user->delete(); // soft delete the account
|
||||||
|
|
||||||
|
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
|
||||||
|
|
||||||
|
expect($control['assigned'])->toBe(1)
|
||||||
|
->and($control['assignedMature'])->toBe(1)
|
||||||
|
->and($control['activated'])->toBe(1) // 2 connections
|
||||||
|
->and($control['costCents'])->toBe(80) // 2 × 40, still charged
|
||||||
|
->and($control['wastedCostCents'])->toBe(80); // never paid → pure burn
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a matured-cohort conversion capped at 100% and prints the matured denominator', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05'); // control matures by the test clock
|
||||||
|
|
||||||
|
// Two paid, matured control users; only one is "activated" (connected a bank).
|
||||||
|
// The old A2P% (Paid ÷ activated = 2 ÷ 1) would print a nonsensical 200%.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()], connections: 1);
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
|
||||||
|
|
||||||
|
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
|
||||||
|
|
||||||
|
expect($control['assignedMature'])->toBe(2)
|
||||||
|
->and($control['activatedMature'])->toBe(1)
|
||||||
|
->and($control['activeMature'])->toBe(2)
|
||||||
|
->and($control['convertedMature'])->toBe(2)
|
||||||
|
->and($control['conversionRate'])->toBe(1.0); // Conv% = Conv ÷ MatU, always ≤ 100%
|
||||||
|
|
||||||
|
Artisan::call('stats:experiment-funnel', ['--no-discord' => true]);
|
||||||
|
$output = Artisan::output();
|
||||||
|
|
||||||
|
expect($output)->toContain('MatU') // matured denominator column is printed
|
||||||
|
->toContain('Conv%')
|
||||||
|
->toContain('100%') // capped, not the old 200% A2P%
|
||||||
|
->not->toContain('200%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a Wilson confidence interval and defers the verdict while samples are small', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup);
|
||||||
|
experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
|
||||||
|
experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup);
|
||||||
|
|
||||||
|
Artisan::call('stats:experiment-funnel', ['--no-discord' => true]);
|
||||||
|
$output = Artisan::output();
|
||||||
|
|
||||||
|
expect($output)->toContain('Significance')
|
||||||
|
->toContain('Wilson')
|
||||||
|
->toContain('Fisher exact') // verdict uses the exact test, not the z-approx
|
||||||
|
->toContain('not significant') // equal 50/50 rates, n=2 per arm → nowhere near
|
||||||
|
->toContain('Small sample'); // min expected conversions < 5
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares significance via the exact test when the separation is real', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
// reduced: 5 matured converters; pay_now: 5 matured non-converters (no sub).
|
||||||
|
// Fisher exact on [[5,0],[0,5]] gives p≈0.008 < 0.0167 (Bonferroni) → significant.
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
experimentUser(SubscriptionExperiment::REDUCED_TRIAL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
|
||||||
|
experimentUser(SubscriptionExperiment::PAY_NOW, $signup);
|
||||||
|
}
|
||||||
|
|
||||||
|
Artisan::call('stats:experiment-funnel', ['--no-discord' => true]);
|
||||||
|
$output = Artisan::output();
|
||||||
|
|
||||||
|
expect($output)->toContain('Fisher exact')
|
||||||
|
->not->toContain('not significant'); // the exact test clears the corrected bar
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measures conversion as ever-charged, not active-now, so churn does not bias it', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
// 1) Still active → converted.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, ['status' => 'active', 'at' => $signup->addDay()]);
|
||||||
|
// 2) Paid then churned after the trial (charged, not refunded) → converted.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
|
||||||
|
'status' => 'canceled', 'at' => $signup, 'trialEndsAt' => $signup->addDays(15), 'endsAt' => $signup->addDays(40),
|
||||||
|
]);
|
||||||
|
// 3) Canceled on/before the trial end (never charged) → NOT converted.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
|
||||||
|
'status' => 'canceled', 'at' => $signup, 'trialEndsAt' => $signup->addDays(15), 'endsAt' => $signup->addDays(10),
|
||||||
|
]);
|
||||||
|
// 4) Refunded → NOT converted.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
|
||||||
|
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDay(), 'refundedAt' => $signup->addDay(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
|
||||||
|
|
||||||
|
expect($control['assignedMature'])->toBe(4)
|
||||||
|
->and($control['convertedMature'])->toBe(2) // #1 active + #2 churned-after-paying
|
||||||
|
->and($control['activeMature'])->toBe(1) // only #1 is active now
|
||||||
|
->and($control['conversionRate'])->toBe(0.5); // 2 ÷ 4, time-invariant
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes churned payers from burn but keeps refunds as burn', function () {
|
||||||
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
// Paid then canceled (not refunded): converted, so its cost is NOT burn.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
|
||||||
|
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDays(20),
|
||||||
|
], connections: 2);
|
||||||
|
// Paid then refunded: zero net revenue, so its cost IS burn.
|
||||||
|
experimentUser(SubscriptionExperiment::CONTROL, $signup, [
|
||||||
|
'status' => 'canceled', 'at' => $signup, 'endsAt' => $signup->addDay(), 'refundedAt' => $signup->addDay(),
|
||||||
|
], connections: 3);
|
||||||
|
|
||||||
|
$control = app(ExperimentFunnelCollector::class)->collect()['variants'][SubscriptionExperiment::CONTROL];
|
||||||
|
|
||||||
|
expect($control['costCents'])->toBe(200) // (2 + 3) × 40, all mature connections
|
||||||
|
->and($control['wastedCostCents'])->toBe(120) // only the refunded user's 3 × 40
|
||||||
|
->and($control['refunded'])->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('scales connection cost by the cost-per-connection argument', function () {
|
it('scales connection cost by the cost-per-connection argument', function () {
|
||||||
$signup = CarbonImmutable::parse('2026-06-05');
|
$signup = CarbonImmutable::parse('2026-06-05');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Services\Stats\BinomialProportion;
|
||||||
|
use App\Services\Stats\ProportionSignificance;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->stats = new ProportionSignificance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the Wilson score interval', function () {
|
||||||
|
[$low, $high] = $this->stats->wilsonInterval(6, 52);
|
||||||
|
expect($low)->toEqualWithDelta(0.0540, 0.0005)
|
||||||
|
->and($high)->toEqualWithDelta(0.2297, 0.0005);
|
||||||
|
|
||||||
|
[$low2, $high2] = $this->stats->wilsonInterval(5, 179);
|
||||||
|
expect($low2)->toEqualWithDelta(0.0120, 0.0005)
|
||||||
|
->and($high2)->toEqualWithDelta(0.0640, 0.0005);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Wilson interval inside [0, 1] at the k=0 and k=n boundaries', function () {
|
||||||
|
expect($this->stats->wilsonInterval(0, 30)[0])->toBe(0.0)
|
||||||
|
->and($this->stats->wilsonInterval(30, 30)[1])->toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes a two-sided Fisher exact p-value', function () {
|
||||||
|
// Real experiment table: reduced 6/52 vs pay_now 5/179.
|
||||||
|
expect($this->stats->fisherExactTwoSided(6, 46, 5, 174))->toEqualWithDelta(0.0182, 0.0005);
|
||||||
|
// Clean separation 5/5 vs 0/5 → 2 * C(5,5)/C(10,5) = 2/252.
|
||||||
|
expect($this->stats->fisherExactTwoSided(5, 0, 0, 5))->toEqualWithDelta(0.007936, 0.00001);
|
||||||
|
// Degenerate margin (no successes anywhere) → p = 1.
|
||||||
|
expect($this->stats->fisherExactTwoSided(0, 10, 0, 10))->toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes the Newcombe difference interval and the descriptive z', function () {
|
||||||
|
$reduced = new BinomialProportion('reduced', 6, 52);
|
||||||
|
$payNow = new BinomialProportion('pay_now', 5, 179);
|
||||||
|
|
||||||
|
[$low, $high] = $this->stats->newcombeDiffInterval($reduced, $payNow);
|
||||||
|
expect($low)->toEqualWithDelta(0.0164, 0.0005)
|
||||||
|
->and($high)->toEqualWithDelta(0.2029, 0.0005)
|
||||||
|
->and($this->stats->twoProportionZ($reduced, $payNow))->toEqualWithDelta(2.607, 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls the borderline real comparison NOT significant under Bonferroni', function () {
|
||||||
|
$result = $this->stats->compare(
|
||||||
|
new BinomialProportion('reduced', 6, 52),
|
||||||
|
new BinomialProportion('pay_now', 5, 179),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fisher p ≈ 0.018 exceeds the corrected bar 0.05/3 ≈ 0.0167 → not a winner yet.
|
||||||
|
expect($result['significant'])->toBeFalse()
|
||||||
|
->and($result['fisherP'])->toEqualWithDelta(0.0182, 0.0005)
|
||||||
|
->and($result['alpha'])->toEqualWithDelta(0.0167, 0.0005)
|
||||||
|
->and($result['minExpectedCount'])->toEqualWithDelta(2.48, 0.05);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls a clean separation significant', function () {
|
||||||
|
$result = $this->stats->compare(
|
||||||
|
new BinomialProportion('a', 5, 5),
|
||||||
|
new BinomialProportion('b', 0, 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fisher p ≈ 0.008 clears the corrected bar.
|
||||||
|
expect($result['significant'])->toBeTrue()
|
||||||
|
->and($result['fisherP'])->toEqualWithDelta(0.007936, 0.00001);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue