0 ? round( $conversions / $impressions, 6 ) : 0.0; $currency_code = strtoupper( (string) ( $currency['code'] ?? 'GBP' ) ); $currency_code = preg_replace( '/[^A-Z0-9_-]/', '', $currency_code ); $currency_code = is_string( $currency_code ) ? substr( $currency_code, 0, 12 ) : ''; if ( $currency_code === '' ) { $currency_code = 'GBP'; } $currency_decimals = max( 0, min( 20, (int) ( $currency['decimals'] ?? 2 ) ) ); $revenue_total = $is_revenue_goal ? abtestkit_analytics_non_negative_number( $stats['revenue'] ?? 0, $currency_decimals ) : null; return [ 'impressions' => $impressions, 'conversions' => $conversions, 'conversion_rate' => $conversion_rate, 'revenue' => [ 'available' => $is_revenue_goal, 'total' => $is_revenue_goal ? $revenue_total : null, 'average_order_value' => $is_revenue_goal && $conversions > 0 ? round( $revenue_total / $conversions, $currency_decimals ) : null, 'revenue_per_impression' => $is_revenue_goal && $impressions > 0 ? round( $revenue_total / $impressions, $currency_decimals + 4 ) : null, 'currency_code' => $is_revenue_goal ? $currency_code : null, 'currency_decimals' => $is_revenue_goal ? $currency_decimals : null, 'currency_is_event_bound' => false, ], 'engagement' => [ 'sample_count' => abtestkit_analytics_non_negative_int( $engagement['count'] ?? 0 ), 'sample_rate' => round( max( 0.0, min( 1.0, $engagement_sample_rate ) ), 4 ), 'average_page_depth_percent' => abtestkit_analytics_non_negative_number( $engagement['avg_scroll'] ?? 0, 1 ), 'average_visible_time_seconds' => abtestkit_analytics_non_negative_number( $engagement['avg_time'] ?? 0, 1 ), ], ]; } /** * Describe whether the configured automatic-decision sample gates are met. * * Manual tests deliberately return a null sufficiency decision once both * variants have data because no automatic threshold applies to them. * * @param array $test Test definition. * @param array $variants Normalised A/B summaries. * @return array */ function abtestkit_analytics_sample_summary( array $test, array $variants ): array { $decision_mode = abtestkit_analytics_identifier( $test['decision_mode'] ?? 'auto', 'auto' ); $decision_rule = abtestkit_analytics_identifier( $test['decision_rule'] ?? 'balanced', 'balanced' ); $is_manual = $decision_mode === 'manual' || $decision_rule === 'manual'; if ( $is_manual ) { $decision_mode = 'manual'; $decision_rule = 'manual'; } $required_impressions = $is_manual ? 0 : abtestkit_analytics_non_negative_int( $test['min_impressions'] ?? 50 ); $required_conversions = $is_manual ? 0 : abtestkit_analytics_non_negative_int( $test['min_conversions'] ?? 5 ); if ( ! $is_manual && $required_impressions <= 0 ) { $required_impressions = 50; } if ( ! $is_manual && $required_conversions <= 0 ) { $required_conversions = 5; } $impressions_a = (int) ( $variants['A']['impressions'] ?? 0 ); $impressions_b = (int) ( $variants['B']['impressions'] ?? 0 ); $total_impressions = $impressions_a + $impressions_b; $total_conversions = (int) ( $variants['A']['conversions'] ?? 0 ) + (int) ( $variants['B']['conversions'] ?? 0 ); $state = 'ready'; $is_sufficient = true; if ( $total_impressions <= 0 ) { $state = 'no_data'; $is_sufficient = false; } elseif ( $impressions_a <= 0 || $impressions_b <= 0 ) { $state = 'single_variant'; $is_sufficient = false; } elseif ( $is_manual ) { $state = 'manual_review'; $is_sufficient = null; } elseif ( $total_impressions < $required_impressions ) { $state = 'collecting_impressions'; $is_sufficient = false; } elseif ( $total_conversions < $required_conversions ) { $state = 'collecting_conversions'; $is_sufficient = false; } return [ 'state' => $state, 'is_sufficient' => $is_sufficient, 'total_impressions' => $total_impressions, 'total_conversions' => $total_conversions, 'required_impressions' => $required_impressions, 'required_conversions' => $required_conversions, 'impression_progress' => $required_impressions > 0 ? round( min( 1, $total_impressions / $required_impressions ), 4 ) : null, 'conversion_progress' => $required_conversions > 0 ? round( min( 1, $total_conversions / $required_conversions ), 4 ) : null, 'decision' => [ 'mode' => $decision_mode, 'rule' => $decision_rule, 'minimum_impressions' => $required_impressions, 'minimum_conversions' => $required_conversions, ], ]; } /** * Return decision-safe comparison fields without declaring a new winner. * * @param array $test Test definition. * @param array $variants Normalised A/B summaries. * @param bool $is_revenue_goal Whether Completed Orders is the goal. * @return array */ function abtestkit_analytics_comparison_summary( array $test, array $variants, bool $is_revenue_goal ): array { $value_a = $is_revenue_goal ? (float) ( $variants['A']['revenue']['revenue_per_impression'] ?? 0 ) : (float) ( $variants['A']['conversion_rate'] ?? 0 ); $value_b = $is_revenue_goal ? (float) ( $variants['B']['revenue']['revenue_per_impression'] ?? 0 ) : (float) ( $variants['B']['conversion_rate'] ?? 0 ); $leader = ''; if ( $value_a > $value_b ) { $leader = 'A'; } elseif ( $value_b > $value_a ) { $leader = 'B'; } $declared_winner = strtoupper( trim( (string) ( $test['winner'] ?? '' ) ) ); if ( ! in_array( $declared_winner, [ 'A', 'B' ], true ) ) { $declared_winner = ''; } $absolute_difference = round( $value_b - $value_a, 6 ); $relative_uplift = $value_a > 0 ? round( ( ( $value_b - $value_a ) / $value_a ) * 100, 2 ) : null; return [ 'primary_metric' => $is_revenue_goal ? 'revenue_per_impression' : 'conversion_rate', 'metric_unit' => $is_revenue_goal ? 'currency' : 'ratio', 'observed_leader' => $leader, 'declared_winner' => $declared_winner, 'winner_basis' => abtestkit_analytics_identifier( $test['winner_basis'] ?? '' ), 'version_a_value' => round( $value_a, 6 ), 'version_b_value' => round( $value_b, 6 ), 'absolute_difference_b_minus_a' => $absolute_difference, 'relative_uplift_b_vs_a_percent' => $relative_uplift, ]; } /** * Build a stable, non-generative recommendation state. * * This deliberately does not call the Bayesian evaluator and cannot declare * or apply a winner. It describes existing state only. * * @param array $test Test definition. * @param array $sample Sample summary. * @param array $comparison Comparison summary. * @param array $health Health summary. * @return array */ function abtestkit_analytics_recommendation_summary( array $test, array $sample, array $comparison, array $health ): array { $status = abtestkit_analytics_identifier( $test['status'] ?? 'paused', 'paused' ); $winner = (string) ( $comparison['declared_winner'] ?? '' ); $leader = (string) ( $comparison['observed_leader'] ?? '' ); $variant = in_array( $winner, [ 'A', 'B' ], true ) ? $winner : $leader; if ( $status === 'complete' ) { return [ 'code' => 'completed_result', 'variant' => $variant ]; } if ( $status === 'winner' && in_array( $winner, [ 'A', 'B' ], true ) ) { return [ 'code' => 'statistical_winner', 'variant' => $winner ]; } $health_status = abtestkit_analytics_identifier( $health['status'] ?? '' ); if ( ! empty( $test['auto_paused_broken'] ) || $health_status === 'broken' ) { return [ 'code' => 'resolve_health_issue', 'variant' => '' ]; } if ( $status === 'draft' ) { return [ 'code' => 'start_when_ready', 'variant' => '' ]; } if ( $status === 'paused' ) { return [ 'code' => 'review_paused_test', 'variant' => $variant ]; } $sample_state = (string) ( $sample['state'] ?? 'no_data' ); if ( $sample_state === 'no_data' ) { return [ 'code' => 'collect_data', 'variant' => '' ]; } if ( $sample_state === 'single_variant' ) { return [ 'code' => 'collect_both_variants', 'variant' => $variant ]; } if ( in_array( $sample_state, [ 'collecting_impressions', 'collecting_conversions' ], true ) ) { return [ 'code' => 'continue_collecting', 'variant' => $variant ]; } if ( $sample_state === 'manual_review' ) { return [ 'code' => $leader === '' ? 'review_tied_result' : 'review_current_leader', 'variant' => $leader, ]; } return [ 'code' => $leader === '' ? 'continue_until_clearer_result' : 'continue_until_decision', 'variant' => $leader, ]; } /** * Keep warning details useful but bounded and free of raw event data. * * @param array $health Existing Health Checker summary. * @return array */ function abtestkit_analytics_health_summary( array $health ): array { $warnings = []; $checks = isset( $health['checks'] ) && is_array( $health['checks'] ) ? $health['checks'] : []; foreach ( array_slice( $checks, 0, 25 ) as $check ) { if ( ! is_array( $check ) ) { continue; } $status = abtestkit_analytics_identifier( $check['status'] ?? '' ); if ( ! in_array( $status, [ 'attention', 'broken' ], true ) ) { continue; } $warnings[] = [ 'id' => abtestkit_analytics_identifier( $check['id'] ?? 'health_warning', 'health_warning' ), 'status' => $status, 'title' => substr( trim( (string) ( $check['title'] ?? '' ) ), 0, 200 ), 'description' => substr( trim( (string) ( $check['description'] ?? '' ) ), 0, 500 ), ]; } return [ 'status' => abtestkit_analytics_identifier( $health['status'] ?? 'unknown', 'unknown' ), 'label' => substr( trim( (string) ( $health['label'] ?? '' ) ), 0, 100 ), 'warning_count' => count( $warnings ), 'warnings' => $warnings, ]; } /** * Normalise the existing bounded performance timeline for the shared schema. * * @param array $timeline Existing aggregate timeline rows. * @param bool $is_revenue_goal Whether Completed Orders is the goal. * @param string $range Requested aggregation range. * @return array */ function abtestkit_analytics_timeline_summary( array $timeline, bool $is_revenue_goal, string $range ): array { $range = in_array( $range, [ 'day', 'week', 'month' ], true ) ? $range : 'day'; $window_days = [ 'day' => 45, 'week' => 182, 'month' => 365, ]; $points = []; foreach ( array_slice( array_values( $timeline ), 0, 400 ) as $row ) { if ( ! is_array( $row ) ) { continue; } $point = [ 'bucket' => substr( trim( (string) ( $row['bucket'] ?? '' ) ), 0, 32 ), 'A' => [], 'B' => [], ]; foreach ( [ 'A', 'B' ] as $variant ) { $variant_row = isset( $row[ $variant ] ) && is_array( $row[ $variant ] ) ? $row[ $variant ] : []; $point[ $variant ] = [ 'impressions' => abtestkit_analytics_non_negative_int( $variant_row['impressions'] ?? 0 ), 'conversions' => abtestkit_analytics_non_negative_int( $is_revenue_goal ? ( $variant_row['purchases'] ?? 0 ) : ( $variant_row['clicks'] ?? 0 ) ), 'revenue' => $is_revenue_goal ? abtestkit_analytics_non_negative_number( $variant_row['revenue'] ?? 0, 2 ) : null, ]; } $points[] = $point; } return [ 'granularity' => $range, 'window_days' => $window_days[ $range ], 'points' => $points, ]; } /** * Normalise one aggregate audience breakdown for the public analytics schema. * * @param mixed $raw Raw aggregate rows. * @param string $dimension Device or geography. * @param bool $is_revenue_goal Whether Completed Orders is the goal. * @return array */ function abtestkit_analytics_audience_breakdown( $raw, string $dimension, bool $is_revenue_goal ): array { $raw = is_array( $raw ) ? $raw : []; if ( ( $raw['availability'] ?? '' ) !== 'collected' ) { return [ 'availability' => 'not_collected', 'values' => [], ]; } $values = []; $reported_impressions = 0; $unknown_impressions = 0; foreach ( array_slice( (array) ( $raw['values'] ?? [] ), 0, 300 ) as $row ) { if ( ! is_array( $row ) ) { continue; } $key = trim( (string) ( $row['key'] ?? '' ) ); if ( $dimension === 'device' ) { $key = in_array( $key, [ 'desktop', 'tablet', 'mobile' ], true ) ? $key : 'unknown'; } else { $key = strtoupper( $key ); $key = preg_match( '/^[A-Z]{2}$/', $key ) ? $key : 'unknown'; } $normalised_row = [ 'key' => $key, 'A' => [], 'B' => [] ]; $row_impressions = 0; foreach ( [ 'A', 'B' ] as $variant ) { $metrics = isset( $row[ $variant ] ) && is_array( $row[ $variant ] ) ? $row[ $variant ] : []; $impressions = abtestkit_analytics_non_negative_int( $metrics['impressions'] ?? 0 ); $conversions = abtestkit_analytics_non_negative_int( $metrics['conversions'] ?? 0 ); $revenue = $is_revenue_goal ? abtestkit_analytics_non_negative_number( $metrics['revenue'] ?? 0, 2 ) : null; $normalised_row[ $variant ] = [ 'impressions' => $impressions, 'conversions' => $conversions, 'conversion_rate' => $impressions > 0 ? round( $conversions / $impressions, 6 ) : 0.0, 'revenue' => $revenue, ]; $row_impressions += $impressions; } $reported_impressions += $row_impressions; if ( $key === 'unknown' ) { $unknown_impressions += $row_impressions; } $values[] = $normalised_row; } return [ 'availability' => 'collected', 'values' => $values, 'coverage' => [ 'reported_impressions' => $reported_impressions, 'known_impressions' => max( 0, $reported_impressions - $unknown_impressions ), 'unknown_impressions' => $unknown_impressions, 'known_share' => $reported_impressions > 0 ? round( ( $reported_impressions - $unknown_impressions ) / $reported_impressions, 4 ) : null, ], ]; } /** * Build the versioned Phase 6 analytics contract from existing aggregates. * * @param array $test Test definition. * @param array $stats Existing A/B statistics. * @param array $engagement Existing A/B engagement aggregate. * @param array $health Existing Health Checker summary. * @param array $timeline Existing bounded timeline rows. * @param string $range Timeline aggregation range. * @param array $context Request-only context such as time and currency. * @return array */ function abtestkit_build_analytics_summary( array $test, array $stats, array $engagement = [], array $health = [], array $timeline = [], string $range = 'day', array $context = [] ): array { $goal = abtestkit_analytics_identifier( $test['goal'] ?? 'clicks', 'clicks' ); $is_revenue_goal = $goal === 'purchase'; $engagement_rate = max( 0.0, min( 1.0, (float) ( $engagement['sample_rate'] ?? 1.0 ) ) ); $currency = isset( $context['currency'] ) && is_array( $context['currency'] ) ? $context['currency'] : [ 'code' => 'GBP', 'decimals' => 2 ]; $variants = []; foreach ( [ 'A', 'B' ] as $variant ) { $variant_stats = isset( $stats[ $variant ] ) && is_array( $stats[ $variant ] ) ? $stats[ $variant ] : []; $variant_engagement = isset( $engagement[ $variant ] ) && is_array( $engagement[ $variant ] ) ? $engagement[ $variant ] : []; $variants[ $variant ] = abtestkit_analytics_variant_summary( $variant_stats, $variant_engagement, $is_revenue_goal, $engagement_rate, $currency ); } $now = abtestkit_analytics_non_negative_int( $context['now'] ?? time() ); $started_at = abtestkit_analytics_non_negative_int( $test['started_at'] ?? 0 ); $finished_at = abtestkit_analytics_non_negative_int( $test['finished_at'] ?? 0 ); $duration_end = $finished_at > 0 ? $finished_at : $now; $elapsed = $started_at > 0 && $duration_end >= $started_at ? $duration_end - $started_at : 0; $paused = abtestkit_analytics_non_negative_int( $test['paused_total'] ?? 0 ); $paused_at = abtestkit_analytics_non_negative_int( $test['paused_at'] ?? 0 ); if ( $paused_at > 0 && $duration_end > $paused_at ) { $paused += $duration_end - $paused_at; } $paused = min( $elapsed, $paused ); $sample = abtestkit_analytics_sample_summary( $test, $variants ); $comparison = abtestkit_analytics_comparison_summary( $test, $variants, $is_revenue_goal ); $health_out = abtestkit_analytics_health_summary( $health ); $recommendation = abtestkit_analytics_recommendation_summary( $test, $sample, $comparison, $health_out ); $click_target_count = abtestkit_analytics_non_negative_int( $context['click_target_count'] ?? 0 ); $has_click_targets = in_array( $goal, [ 'clicks', 'destination_url' ], true ); $audience_breakdowns = isset( $context['audience_breakdowns'] ) && is_array( $context['audience_breakdowns'] ) ? $context['audience_breakdowns'] : []; $device_breakdown = abtestkit_analytics_audience_breakdown( $audience_breakdowns['device'] ?? [], 'device', $is_revenue_goal ); $geography_breakdown = abtestkit_analytics_audience_breakdown( $audience_breakdowns['geography'] ?? [], 'geography', $is_revenue_goal ); $limitations = [ 'session_based_impressions', 'engagement_exit_sample', 'historical_audience_dimensions_unknown', 'country_resolution_local_only', ]; if ( $has_click_targets ) { $limitations[] = 'click_target_not_attributed'; } if ( $is_revenue_goal ) { $limitations[] = 'purchase_currency_not_event_bound'; } return [ 'schema_version' => 2, 'generated_at' => $now, 'test' => [ 'id' => substr( trim( (string) ( $test['id'] ?? '' ) ), 0, 64 ), 'kind' => abtestkit_analytics_identifier( $test['kind'] ?? 'page', 'page' ), 'status' => abtestkit_analytics_identifier( $test['status'] ?? 'paused', 'paused' ), 'goal' => $goal, 'started_at' => $started_at, 'finished_at' => $finished_at, 'duration' => [ 'elapsed_seconds' => $elapsed, 'paused_seconds' => $paused, 'active_seconds' => max( 0, $elapsed - $paused ), ], 'decision' => $sample['decision'], ], 'sample' => array_diff_key( $sample, [ 'decision' => true ] ), 'variants' => $variants, 'comparison' => $comparison, 'recommendation' => $recommendation, 'health' => $health_out, 'timeline' => abtestkit_analytics_timeline_summary( $timeline, $is_revenue_goal, $range ), 'breakdowns' => [ 'click_targets' => [ 'availability' => $has_click_targets ? 'aggregate_only' : 'not_applicable', 'configured_count' => $has_click_targets ? $click_target_count : 0, ], 'device' => $device_breakdown, 'geography' => $geography_breakdown, ], 'audience' => [ 'eligibility' => 'all_visitors', 'targeting' => [ 'availability' => 'not_configured', ], 'dimensions' => [ 'device' => [ 'targeting' => 'not_configured', 'reporting' => (string) $device_breakdown['availability'], ], 'geography' => [ 'targeting' => 'not_configured', 'reporting' => (string) $geography_breakdown['availability'], ], ], ], 'limitations' => $limitations, ]; }