Experiment gate (expgate)
Replayable decision records for AI-assisted go/no-go decisions. This small, deterministic workflow handles the step that follows offline release gates: deciding whether a real-time decision model that is already in an online A/B test should be shipped, held for more data, or rolled back.
It reads the aggregate summary an experimentation platform exports (units per arm, successes for proportion metrics, mean and standard deviation for mean metrics) and writes a decision record. It uses only the Python standard library, so it has no dependencies and no lockfile.
Decision rules
Rules are applied in order; the first match wins.
| Decision | When |
|---|---|
INVALID |
Sample-ratio mismatch: the observed assignment split differs from the design (chi-square, srm_alpha, default 0.001). No effect estimate is trusted. |
ROLLBACK |
The primary metric is significantly worse, or a guardrail is significantly worse and its point degradation exceeds its margin. |
SHIP |
The primary metric is significantly better and every guardrail is shown non-inferior (its upper degradation bound is below its margin). |
HOLD |
Anything else: the experiment is inconclusive. |
The primary metric uses a one-sided test at alpha (default 0.05). Guardrail
bounds are one-sided and Bonferroni-adjusted across guardrails. Proportions use
the unpooled Wald standard error; means use the Welch standard error; ratio
metrics use a delta-method standard error (see Ratio metrics).
All are large-sample approximations. A metric can optionally be estimated with CUPED
regression adjustment, which narrows its interval (see
Variance reduction). A test that is checked while it
runs can replace every bound with an always-valid confidence sequence (see
Sequential monitoring).
Every metric reports mde = (z + z_power) * se, using its critical value,
observed standard error, and policy.power (default 0.8). For fixed-horizon
analysis, mde_basis is fixed_horizon_normal_approximation: approximate
single-metric detectability at the current sample size, not a guarantee that all
guardrails pass or the experiment ships. For sequential analysis it is
current_look_normal_approximation: a sensitivity estimate for this look,
not a guarantee of power under repeated looks or a stopping rule. The formula
and thresholds are unchanged. A zero standard error makes the metric inconclusive;
its numerical MDE of zero is not evidence of detectability.
Quick start
Install with Python 3.11 or later (the first PyPI release is pending):
pip install expgate
experiment-gate --input examples/offer-ranker-v2.json --out review-run --require-ship
experiment-gate-verify review-run/decision.json
The examples live in this repository; supply your own exported summary after installation. From this directory:
python3 -m unittest discover -s tests
python3 -m expgate.run --all-scenarios --out review-run
python3 -m expgate.run --input examples/offer-ranker-v2.json --out review-run/example --require-ship
python -m expgate is also supported; python -m expgate.run remains available.
--out must be new or empty. Each evaluation writes decision.json (sorted
keys; byte-identical across runs for the same input) and a readable
DECISION.md. --input takes several summaries at once (--input exports/*.json);
each record then goes to --out/<experiment>/, so every summary must name its
experiment, and the names must be distinct (ignoring case) and usable as a
directory name. Every summary is read and evaluated before anything is written,
so one invalid file leaves no partial records. The entire run is written into a
staging directory next to --out and atomically published with os.replace; a
write failure leaves the target untouched and the run can be retried. --require-ship exits 1 unless every decision is SHIP, so the
command can gate a promotion job.
| Exit | Evaluation CLI | Verification CLI |
|---|---|---|
| 0 | Records written; required SHIP condition met, if requested | All records verified |
| 1 | Completed, but --require-ship was not met |
Hash or replay content mismatch |
| 2 | Invalid input, usage, unreadable file, or output failure | Unreadable/invalid record, usage, or unsupported/legacy schema |
Without --require-ship, writing a HOLD, ROLLBACK, or INVALID decision succeeds
with exit 0; inspect the record or request the gate explicitly.
From the repository root, make expgate-test and
make expgate-run EXPGATE_OUT=/abs/new/dir run the same commands.
Input format
See examples/offer-ranker-v2.json. Exactly
one metric has "role": "primary"; every other metric is a "guardrail" with a
non-negative margin in the metric's own units. direction states which way is
better (increase or decrease). policy and assignment are optional;
policy.sequential turns on sequential monitoring.
The summary is validated before any statistic is computed: counts must be
integers within range, means, standard deviations and guardrail margins must be finite,
alpha, srm_alpha, power and expected_treatment_share must lie in (0, 1), and
policy.sequential.planned_units must be an integer of at least 2. experiment,
when present, must be a non-empty string. The allowed top-level keys are
experiment, description, seed (synthetic metadata), metrics, arms,
policy, and assignment. Metric specs allow only type, role, direction,
and margin; arm metrics must all be declared in the specs. Unknown keys at
the summary top level, in metric specs, in policy,
policy.sequential, assignment, an arm's metric statistics, and a covariate
are rejected rather than ignored, so a misspelled setting such as alpah cannot
silently fall back to its default.
Any violation exits 2 with the offending field named, so malformed input is
never mistaken for a --require-ship refusal (exit 1). Every metric with zero
standard error (including zero-event or all-event proportions, zero-variance
means/ratios, and perfectly adjusted CUPED outcomes) is INCONCLUSIVE; it can
neither establish significant improvement/harm nor non-inferiority. A zero-error
primary or guardrail therefore prevents SHIP.
Decision records and offline verification
decision.json preserves the existing decision, reasons, policy, sample-ratio,
metrics, and expgate_version fields. New records have integer schema_version: 2,
produced_by: {"name": "expgate", "version": "0.1.0"}, the full parsed input, and
input_sha256. The input digest is lowercase SHA-256 of canonical JSON:
json.dumps(input, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
encoded as UTF-8. DECISION.md shows the SHA-256 of the actual decision.json
bytes (including formatting), which is a different digest.
python -m expgate.verify review-run/decision.json
experiment-gate-verify review-run/*/decision.json
Verification recomputes the input digest and calls evaluate with the running
version. It names mismatches in the decision, reasons, metrics, experiment,
policy, sample-ratio, and sequential fields using canonical JSON comparison
(including value types). It needs no network access. Missing
schema_version means schema 1: existing JSON remains readable, but lacks the
embedded input needed for verification and exits 2 with an explanation. Future
versions may produce different replay results; a mismatch is reported, not hidden.
Hashes detect changes, not forgery or authenticity: someone who rewrites the input
and recomputes the report can produce a consistent record.
The public API exports evaluate, verify_record, and __version__ from
expgate. verify_record(record) returns a list of mismatched field names and
raises ValueError for invalid/unsupported records.
Bundled examples
All examples are hand-written schema demonstrations, not live-model, calibration, adoption, or production evidence:
| Input | Demonstrates |
|---|---|
examples/offer-ranker-v2.json |
Proportion primary and proportion/mean guardrails |
examples/ratio.json |
Revenue per session with user-level numerator/denominator summaries |
examples/cuped.json |
Mean primary adjusted by one pre-assignment covariate |
examples/sequential.json |
An interim look toward 200,000 planned units |
experiment-gate --input examples/*.json --out example-records
experiment-gate-verify example-records/*/decision.json
Ratio metrics
Some metrics are a ratio of two per-unit totals: revenue per session, clicks per page view,
when users (not sessions) are randomized. Sessions from the same user are correlated, so
treating them as independent draws understates the standard error. Declare the metric with
"type": "ratio" and give each arm the per-unit mean and sample standard deviation of the
numerator and the denominator, and their correlation across units:
"revenue_per_session": {"numerator": {"mean": 31.2, "sd": 40.5},
"denominator": {"mean": 3.1, "sd": 2.4}, "corr": 0.62}
The arm's value is numerator.mean / denominator.mean, and its variance comes from the
first-order delta method,
(sd_y^2 - 2 R corr sd_y sd_x + R^2 sd_x^2) / (mean_x^2 n) with R the ratio and n the
arm's units. The denominator mean must be positive. Ratio metrics take any role and direction,
work with sequential monitoring, and do not take a CUPED covariate.
In 1,000 simulated A/A tests that randomize 2,000 users per arm, each with a varying number of sessions and a user-level spending propensity, the delta-method standard error averaged 0.212 against an empirical spread of 0.211, and a one-sided test at 0.05 shipped 4.6% of them. Treating sessions as independent shipped 9.8%.
Variance reduction (CUPED)
If you have a pre-experiment measurement of the same unit that predicts the outcome (last
month's revenue for this month's revenue, say), add a covariate summary to that metric in
both arms (the numbers below are illustrative):
"revenue_per_user": {"mean": 20.57, "sd": 8.1,
"covariate": {"mean": 20.46, "sd": 7.9, "corr": 0.8}}
covariate.mean and covariate.sd describe the pre-experiment value, and corr is its
correlation with the outcome within the arm. Proportion metrics take the same block next to
successes. The estimate becomes (ybar_t - ybar_c) - theta * (xbar_t - xbar_c), with one
theta shared by both arms: the pooled within-arm regression slope. The standard error
shrinks by about 1 - rho^2 in variance, and the adjustment also removes the part of the
difference caused by a chance imbalance in the covariate. The decision rules do not change, and
a metric's row in decision.json gains an adjustment object (theta, the unadjusted
difference and standard error, and the variance reduction).
Things the tool cannot check for you:
- The covariate must be measured before assignment and must not be affected by the treatment. A post-treatment covariate biases the estimate.
- The adjustment can move a single experiment's estimate either way. In the
cupedscenario the treatment arm's covariate runs high by chance, the unadjusted lift is inflated, and the adjusted analysis holds. Across 300 simulated experiments with a real lift of 0.4 the adjusted analysis ships 80% of the time against 47% unadjusted. With no real lift over 1,500 runs they ship 5.5% and 5.9% of the time, consistent with the nominal 5%. thetais treated as known. Its estimation error is negligible at the sample sizes this tool targets, but it is ignored.
Sequential monitoring
The default bounds assume the summary is evaluated once, at a sample size fixed in advance.
Evaluating it every day and acting on the first SHIP or ROLLBACK inflates the error rates:
in 2,000 simulated A/A tests checked at 20 evenly spaced looks, re-applying the fixed-horizon
bounds at each look shipped 19.8% of them and rolled back 22.0%, against a nominal 5%.
To check a running test, declare the total sample size (both arms) the test is planned to reach:
"policy": {"alpha": 0.05, "sequential": {"planned_units": 60000}}
Every bound then uses a normal-approximation confidence sequence: a one-sided normal-mixture
sequential probability ratio test on the same normal approximation, inverted into a bound. Under the
known-variance normal model, probability at least 1 - alpha applies to each bound
at every look at once. With observed standard errors this is approximate, so the summary can be evaluated
as often as the platform refreshes it, and the experiment can stop at the first SHIP or
ROLLBACK. The decision rules, the Bonferroni split across guardrails and CUPED are unchanged;
only the multiplier on each standard error (the z in a metric's row) is larger. decision.json
gains a sequential object (planned and current units, and the information fraction), and
DECISION.md gains one line. A summary without policy.sequential uses fixed-horizon bounds.
planned_units tunes the mixing variance so the bound is narrowest at the planned size. It must be
fixed before the test starts; choosing it after seeing the data voids the guarantee. Under the known-variance normal model the bound
stays valid before and after the plan, only wider; the plug-in implementation remains approximate. A HOLD at or past the plan says that the
planned sample size was reached without a decision.
The price is power at the plan. In the same simulation, with a real conversion lift from 0.100 to
0.108 and 60,000 planned units, a single fixed-horizon analysis at the plan ships 95% of tests.
Sequential monitoring over 20 looks ships 74% of them, and those it ships stop on average at 56% of
the planned sample. Without a real effect it ships 1.0% and rolls back 1.2%. At the plan the
primary multiplier is 2.77 standard errors against 1.64 for the fixed-horizon test at
alpha = 0.05. Use sequential monitoring when stopping early (above all, rolling back a harmful
candidate early) is worth that cost.
Synthetic scenarios
--scenario / --all-scenarios simulate an offer-ranking model with a fixed
seed (expgate/generator.py):
| Scenario | Expected decision |
|---|---|
win |
SHIP |
flat |
HOLD |
guardrail-breach |
ROLLBACK (latency) |
regression |
ROLLBACK (primary) |
srm |
INVALID |
cuped |
HOLD (a revenue lift that looks significant only because of a covariate imbalance) |
early-regression |
ROLLBACK (primary) at an interim look, a quarter of the way to the planned sample size |
Limitations
The simulation figures above describe synthetic experiments, not measured product performance or calibration on live-model outcomes.
- Without
policy.sequentialthe bounds are fixed-horizon: checking results repeatedly and stopping early inflates error rates. - Sequential bounds plug in the observed standard error at each look, as the fixed-horizon
bounds do. The guarantee is exact for normal estimates with known variance and holds only
approximately at small samples. The sample-ratio check is not sequential: re-running it at
every look raises its false alarm rate above
srm_alpha. - CUPED is supported for one covariate per proportion or mean metric. There is no CUPED for ratio metrics, no multi-covariate adjustment, and no heterogeneous-effect analysis.
- The delta method is a first-order approximation. It needs enough units that the denominator mean is estimated well away from zero.
- Synthetic scenarios show that the rules behave as specified. They say nothing about any real product's effects.
Metadata
Release files for expgate 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| expgate-0.1.0.tar.gz | 41.1 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| expgate-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 66.3 kB
Release files / expgate-0.1.0.tar.gz
| Download URL | expgate-0.1.0.tar.gz |
|---|---|
| Size | 41.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
c1df1c013e4cb21d76f450e39f56a884b15dcc162f98da69a3c782586579c46f
|
|
BLAKE2b-256 checksum How to use checksums |
842949d909a1cb1d4e6759cc98666f7cad041faf29c8fdffb1d70f95bec3df7e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logRelease files / expgate-0.1.0-py3-none-any.whl
| Download URL | expgate-0.1.0-py3-none-any.whl |
|---|---|
| Size | 25.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2579bb0a8ee18e2bfa8e20d60d69dcb71e765b70ce939b413dd0fde87c39bc09
|
|
BLAKE2b-256 checksum How to use checksums |
c83b4713495704762b54061dd32afac7ecbd71653c33c277c429ab31fe280330
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logSecurity for expgate 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for expgate-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 20, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.