Files
consentos/apps/admin-ui/src/utils/statistics.ts
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

257 lines
7.6 KiB
TypeScript

/**
* Statistical significance utilities for A/B test analysis.
*
* Uses the chi-squared test to determine whether observed differences
* in consent rates between variants are statistically significant.
*/
/**
* Chi-squared cumulative distribution function approximation.
*
* Uses the regularised incomplete gamma function for 1 degree of freedom
* (2-variant comparison). Returns P(X <= x) for chi-squared distribution.
*/
function chiSquaredCDF(x: number, df: number): number {
if (x <= 0) return 0;
// Use the regularised lower incomplete gamma function
// For integer/half-integer df, this converges quickly
const k = df / 2;
const xHalf = x / 2;
return regularisedGammaP(k, xHalf);
}
/** Regularised lower incomplete gamma function P(a, x) via series expansion. */
function regularisedGammaP(a: number, x: number): number {
if (x < 0) return 0;
if (x === 0) return 0;
// Use series expansion for x < a + 1
if (x < a + 1) {
let sum = 1 / a;
let term = 1 / a;
for (let n = 1; n < 200; n++) {
term *= x / (a + n);
sum += term;
if (Math.abs(term) < 1e-10 * Math.abs(sum)) break;
}
return sum * Math.exp(-x + a * Math.log(x) - lnGamma(a));
}
// Use continued fraction for x >= a + 1
return 1 - regularisedGammaQ(a, x);
}
/** Regularised upper incomplete gamma function Q(a, x) via continued fraction. */
function regularisedGammaQ(a: number, x: number): number {
let c = 1e-30;
let d = 1 / (x + 1 - a);
let h = d;
for (let n = 1; n < 200; n++) {
const an = -n * (n - a);
const bn = x + 2 * n + 1 - a;
d = bn + an * d;
if (Math.abs(d) < 1e-30) d = 1e-30;
c = bn + an / c;
if (Math.abs(c) < 1e-30) c = 1e-30;
d = 1 / d;
const delta = d * c;
h *= delta;
if (Math.abs(delta - 1) < 1e-10) break;
}
return Math.exp(-x + a * Math.log(x) - lnGamma(a)) * h;
}
/** Natural log of the Gamma function using Lanczos approximation. */
function lnGamma(z: number): number {
const g = 7;
const c = [
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
];
if (z < 0.5) {
return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z);
}
z -= 1;
let x = c[0];
for (let i = 1; i < g + 2; i++) {
x += c[i] / (z + i);
}
const t = z + g + 0.5;
return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x);
}
export type SignificanceLevel = 'not_enough_data' | 'not_significant' | 'trending' | 'significant';
export interface SignificanceResult {
level: SignificanceLevel;
/** Human-readable label */
label: string;
/** p-value (0 to 1), lower = more significant */
pValue: number | null;
/** Confidence percentage (0 to 100) */
confidence: number | null;
}
/**
* Perform a chi-squared test comparing conversion rates between variants.
*
* @param observed Array of { successes, total } per variant
* @param minSampleSize Minimum total observations before testing (default: 100)
*/
export function chiSquaredTest(
observed: { successes: number; total: number }[],
minSampleSize: number = 100,
): SignificanceResult {
const totalObservations = observed.reduce((sum, v) => sum + v.total, 0);
if (totalObservations < minSampleSize) {
return {
level: 'not_enough_data',
label: 'Not enough data',
pValue: null,
confidence: null,
};
}
const totalSuccesses = observed.reduce((sum, v) => sum + v.successes, 0);
const overallRate = totalSuccesses / totalObservations;
if (overallRate === 0 || overallRate === 1) {
return {
level: 'not_significant',
label: 'Not significant',
pValue: 1,
confidence: 0,
};
}
// Calculate chi-squared statistic
let chiSq = 0;
for (const variant of observed) {
const expectedSuccess = variant.total * overallRate;
const expectedFailure = variant.total * (1 - overallRate);
if (expectedSuccess > 0) {
chiSq += Math.pow(variant.successes - expectedSuccess, 2) / expectedSuccess;
}
if (expectedFailure > 0) {
const failures = variant.total - variant.successes;
chiSq += Math.pow(failures - expectedFailure, 2) / expectedFailure;
}
}
const df = observed.length - 1;
const pValue = 1 - chiSquaredCDF(chiSq, df);
const confidence = (1 - pValue) * 100;
if (confidence >= 95) {
return { level: 'significant', label: 'Significant (>95%)', pValue, confidence };
}
if (confidence >= 90) {
return { level: 'trending', label: 'Trending (>90%)', pValue, confidence };
}
return { level: 'not_significant', label: 'Not significant', pValue, confidence };
}
/**
* Calculate the recommended sample size per variant.
*
* Uses the formula for a two-proportion z-test:
* n = (Z_alpha/2 + Z_beta)^2 * (p1(1-p1) + p2(1-p2)) / (p1 - p2)^2
*
* @param baselineRate Current accept-all rate (0-1)
* @param minimumDetectableEffect Minimum relative change to detect (e.g. 0.05 for 5%)
* @param power Statistical power (default: 0.8)
* @param alpha Significance level (default: 0.05)
*/
export function requiredSampleSize(
baselineRate: number,
minimumDetectableEffect: number,
power: number = 0.8,
alpha: number = 0.05,
): number {
if (baselineRate <= 0 || baselineRate >= 1) return 0;
if (minimumDetectableEffect <= 0) return Infinity;
const p1 = baselineRate;
const p2 = baselineRate * (1 + minimumDetectableEffect);
if (p2 >= 1) return Infinity;
const zAlpha = normalQuantile(1 - alpha / 2);
const zBeta = normalQuantile(power);
const numerator = Math.pow(zAlpha + zBeta, 2) * (p1 * (1 - p1) + p2 * (1 - p2));
const denominator = Math.pow(p1 - p2, 2);
return Math.ceil(numerator / denominator);
}
/**
* Approximate inverse normal CDF (quantile function).
*
* Uses the rational approximation from Peter Acklam:
* https://web.archive.org/web/20151030215612/http://home.online.no/~pjacklam/notes/invnorm/
*/
function normalQuantile(p: number): number {
if (p <= 0) return -Infinity;
if (p >= 1) return Infinity;
if (p === 0.5) return 0;
// Coefficients for the rational approximation
const a1 = -3.969683028665376e+01;
const a2 = 2.209460984245205e+02;
const a3 = -2.759285104469687e+02;
const a4 = 1.383577518672690e+02;
const a5 = -3.066479806614716e+01;
const a6 = 2.506628277459239e+00;
const b1 = -5.447609879822406e+01;
const b2 = 1.615858368580409e+02;
const b3 = -1.556989798598866e+02;
const b4 = 6.680131188771972e+01;
const b5 = -1.328068155288572e+01;
const c1 = -7.784894002430293e-03;
const c2 = -3.223964580411365e-01;
const c3 = -2.400758277161838e+00;
const c4 = -2.549732539343734e+00;
const c5 = 4.374664141464968e+00;
const c6 = 2.938163982698783e+00;
const d1 = 7.784695709041462e-03;
const d2 = 3.224671290700398e-01;
const d3 = 2.445134137142996e+00;
const d4 = 3.754408661907416e+00;
const pLow = 0.02425;
const pHigh = 1 - pLow;
let q: number;
let r: number;
if (p < pLow) {
// Rational approximation for lower region
q = Math.sqrt(-2 * Math.log(p));
return (((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
} else if (p <= pHigh) {
// Rational approximation for central region
q = p - 0.5;
r = q * q;
return (((((a1 * r + a2) * r + a3) * r + a4) * r + a5) * r + a6) * q /
(((((b1 * r + b2) * r + b3) * r + b4) * r + b5) * r + 1);
} else {
// Rational approximation for upper region
q = Math.sqrt(-2 * Math.log(1 - p));
return -(((((c1 * q + c2) * q + c3) * q + c4) * q + c5) * q + c6) /
((((d1 * q + d2) * q + d3) * q + d4) * q + 1);
}
}