gallop-pds 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. gallop_pds-0.2.0/LICENSE +21 -0
  2. gallop_pds-0.2.0/PKG-INFO +118 -0
  3. gallop_pds-0.2.0/README.md +94 -0
  4. gallop_pds-0.2.0/pyproject.toml +40 -0
  5. gallop_pds-0.2.0/setup.cfg +4 -0
  6. gallop_pds-0.2.0/src/gallop/__init__.py +17 -0
  7. gallop_pds-0.2.0/src/gallop/examples/__init__.py +0 -0
  8. gallop_pds-0.2.0/src/gallop/examples/quickstart.py +89 -0
  9. gallop_pds-0.2.0/src/gallop/power.py +118 -0
  10. gallop_pds-0.2.0/src/gallop/priors.py +160 -0
  11. gallop_pds-0.2.0/src/gallop/sequential.py +123 -0
  12. gallop_pds-0.2.0/src/gallop/shrink.py +117 -0
  13. gallop_pds-0.2.0/src/gallop/templates/metric-registry.schema.json +21 -0
  14. gallop_pds-0.2.0/src/gallop/templates/prior-store.schema.json +24 -0
  15. gallop_pds-0.2.0/src/gallop/trust.py +174 -0
  16. gallop_pds-0.2.0/src/gallop/validate.py +348 -0
  17. gallop_pds-0.2.0/src/gallop/variance.py +111 -0
  18. gallop_pds-0.2.0/src/gallop_pds.egg-info/PKG-INFO +118 -0
  19. gallop_pds-0.2.0/src/gallop_pds.egg-info/SOURCES.txt +28 -0
  20. gallop_pds-0.2.0/src/gallop_pds.egg-info/dependency_links.txt +1 -0
  21. gallop_pds-0.2.0/src/gallop_pds.egg-info/requires.txt +7 -0
  22. gallop_pds-0.2.0/src/gallop_pds.egg-info/top_level.txt +1 -0
  23. gallop_pds-0.2.0/tests/test_power.py +39 -0
  24. gallop_pds-0.2.0/tests/test_priors.py +89 -0
  25. gallop_pds-0.2.0/tests/test_sequential.py +60 -0
  26. gallop_pds-0.2.0/tests/test_shrink.py +56 -0
  27. gallop_pds-0.2.0/tests/test_sql_templates.py +23 -0
  28. gallop_pds-0.2.0/tests/test_trust.py +41 -0
  29. gallop_pds-0.2.0/tests/test_validate.py +118 -0
  30. gallop_pds-0.2.0/tests/test_variance.py +41 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 0trm
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: gallop-pds
3
+ Version: 0.2.0
4
+ Summary: Product data science checks: power from priors, SRM, CUPED, always-valid inference, empirical Bayes shrinkage, out-of-time model validation, and the prior store they read.
5
+ Author: 0trm
6
+ License: MIT
7
+ Project-URL: Homepage, https://0trm.github.io/gallop/
8
+ Project-URL: Repository, https://github.com/0trm/gallop
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Information Analysis
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy>=1.26
18
+ Requires-Dist: pandas>=2.0
19
+ Requires-Dist: scipy>=1.11
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+ Requires-Dist: ruff>=0.5; extra == "dev"
23
+ Dynamic: license-file
24
+
25
+ <p align="right">
26
+ <img src="site/assets/mark.svg" width="300" alt="Three riders carried on one galloping horse">
27
+ </p>
28
+
29
+ # gallop
30
+
31
+ **Routes a product question to the method it deserves, then runs that method with checks in place.**
32
+
33
+ A product data science system, packaged as agent skills. Most questions leave at
34
+ intake without an analysis. The ones that stay get the method that matches how
35
+ treatment was assigned, and the checks that decide whether the result is a
36
+ result. What they teach is written back, so the next question starts smaller.
37
+
38
+ ## The map
39
+
40
+ A question enters at the left and leaves as a decision. Measurement is a
41
+ foundation rather than a phase, because what ships changes the data. Theory is a
42
+ ceiling rather than a report, because what you learn has to outlive the test that
43
+ produced it.
44
+
45
+ Three method buckets, a floor underneath them, a memory above them, and a layer
46
+ of judgment on top. Each bucket has its own question, its own output, and its own
47
+ failure mode:
48
+
49
+ | Bucket | Asks | Hands back | Fails by |
50
+ |---|---|---|---|
51
+ | Description | What happened? | A hypothesis | Being mistaken for causation |
52
+ | Causation | Did this change cause that? | An effect size | An invalid comparison group |
53
+ | Prediction | What will happen? Who gets what? | A forecast, a ranking, an allocation | Breaking the moment you intervene |
54
+
55
+ Experimentation and causal inference share all three, so they are one bucket
56
+ separated only by who did the randomising: you, or the world.
57
+
58
+ **[Read the full map](https://0trm.github.io/gallop/map/)** ·
59
+ **[Read the intake algorithm](https://0trm.github.io/gallop/intake/)**
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ # Claude Code
65
+ /plugin marketplace add 0trm/gallop
66
+ /plugin install gallop@gallop
67
+
68
+ # any other agent, or none: a skill is a directory of markdown
69
+ cp -r gallop/skills/reading-experiments .claude/skills/
70
+
71
+ # the package; the import name is gallop
72
+ pip install gallop-pds
73
+ ```
74
+
75
+ ## The five-minute path
76
+
77
+ One command, one synthetic dataset, every check once:
78
+
79
+ ```bash
80
+ python -m gallop.examples.quickstart
81
+ ```
82
+
83
+ Fifteen seconds of runtime: an MDE, an SRM verdict, an exposure ratio, a
84
+ CUPED-adjusted effect with an always-valid interval, and the same effect shrunk
85
+ toward a seeded prior store. The full loop, from a question arriving to the
86
+ prior store changing on disk, is `python examples/end-to-end/run_loop.py`.
87
+
88
+ ## The skills
89
+
90
+ <!-- skills-table:begin (generated by site/build.py; do not edit) -->
91
+ | Skill | What it decides | Reach for it when |
92
+ |---|---|---|
93
+ | [`routing-questions`](skills/routing-questions/SKILL.md) | Whether this becomes work at all, and which skill it becomes | a product, analytics, or experimentation request first arrives, when someone asks for a deep dive or a dashboard, or before opening a query editor on any question about impact, lift, or whether something worked |
94
+ | [`defining-metrics`](skills/defining-metrics/SKILL.md) | A metric turned into a computation, a source of truth, and a statement of how it will be gamed | defining a north-star or guardrail metric, when two dashboards disagree on the same number, when arbitrating between conflicting metric definitions, or when a readout depends on a metric nobody has validated |
95
+ | [`designing-experiments`](skills/designing-experiments/SKILL.md) | The four choices that cannot be repaired after launch, with the MDE from the prior store | planning, powering, or pre-registering an experiment, when deciding whether a question is testable at the available traffic, or when a feature is about to ship without a flag |
96
+ | [`reading-experiments`](skills/reading-experiments/SKILL.md) | Whether the result is a result: SRM, exposure, the sequential bound, CUPED, shrinkage | analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout |
97
+ | [`choosing-causal-designs`](skills/choosing-causal-designs/SKILL.md) | The method that matches how assignment happened, and the exit that says there is no comparison group | measuring the impact of something already rolled out, a launch, a migration, a pricing change, or a campaign that reached everyone at once |
98
+ | [`automating-decisions`](skills/automating-decisions/SKILL.md) | Whether a repeated decision belongs to a model, validated out of time, and the holdout that measures its impact | someone asks for a churn, propensity, LTV, scoring, forecasting, uplift, recommendation or allocation model, when a model's offline accuracy is offered as evidence that something worked, or when deciding who gets an offer, a discount or an intervention |
99
+ | [`writing-readouts`](skills/writing-readouts/SKILL.md) | The decision rule first, the result last; the belief filed where the next question starts | a test finishes, when documenting a shipped or killed decision, when writing up a null result or a rollback, or when a question needs an entry someone can find in a year |
100
+ <!-- skills-table:end -->
101
+
102
+ ## What this is not
103
+
104
+ Not an experimentation platform. It does not assign traffic, hold flags, or
105
+ replace your warehouse. It assumes those exist and writes the part that decides
106
+ whether the number they produced is true.
107
+
108
+ ## More
109
+
110
+ The site renders the skills and the two arguments:
111
+ [the map](https://0trm.github.io/gallop/map/) ·
112
+ [the intake](https://0trm.github.io/gallop/intake/) ·
113
+ [the theory layer](https://0trm.github.io/gallop/theory/) ·
114
+ [install](https://0trm.github.io/gallop/install/)
115
+
116
+ ## Licence
117
+
118
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,94 @@
1
+ <p align="right">
2
+ <img src="site/assets/mark.svg" width="300" alt="Three riders carried on one galloping horse">
3
+ </p>
4
+
5
+ # gallop
6
+
7
+ **Routes a product question to the method it deserves, then runs that method with checks in place.**
8
+
9
+ A product data science system, packaged as agent skills. Most questions leave at
10
+ intake without an analysis. The ones that stay get the method that matches how
11
+ treatment was assigned, and the checks that decide whether the result is a
12
+ result. What they teach is written back, so the next question starts smaller.
13
+
14
+ ## The map
15
+
16
+ A question enters at the left and leaves as a decision. Measurement is a
17
+ foundation rather than a phase, because what ships changes the data. Theory is a
18
+ ceiling rather than a report, because what you learn has to outlive the test that
19
+ produced it.
20
+
21
+ Three method buckets, a floor underneath them, a memory above them, and a layer
22
+ of judgment on top. Each bucket has its own question, its own output, and its own
23
+ failure mode:
24
+
25
+ | Bucket | Asks | Hands back | Fails by |
26
+ |---|---|---|---|
27
+ | Description | What happened? | A hypothesis | Being mistaken for causation |
28
+ | Causation | Did this change cause that? | An effect size | An invalid comparison group |
29
+ | Prediction | What will happen? Who gets what? | A forecast, a ranking, an allocation | Breaking the moment you intervene |
30
+
31
+ Experimentation and causal inference share all three, so they are one bucket
32
+ separated only by who did the randomising: you, or the world.
33
+
34
+ **[Read the full map](https://0trm.github.io/gallop/map/)** ·
35
+ **[Read the intake algorithm](https://0trm.github.io/gallop/intake/)**
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ # Claude Code
41
+ /plugin marketplace add 0trm/gallop
42
+ /plugin install gallop@gallop
43
+
44
+ # any other agent, or none: a skill is a directory of markdown
45
+ cp -r gallop/skills/reading-experiments .claude/skills/
46
+
47
+ # the package; the import name is gallop
48
+ pip install gallop-pds
49
+ ```
50
+
51
+ ## The five-minute path
52
+
53
+ One command, one synthetic dataset, every check once:
54
+
55
+ ```bash
56
+ python -m gallop.examples.quickstart
57
+ ```
58
+
59
+ Fifteen seconds of runtime: an MDE, an SRM verdict, an exposure ratio, a
60
+ CUPED-adjusted effect with an always-valid interval, and the same effect shrunk
61
+ toward a seeded prior store. The full loop, from a question arriving to the
62
+ prior store changing on disk, is `python examples/end-to-end/run_loop.py`.
63
+
64
+ ## The skills
65
+
66
+ <!-- skills-table:begin (generated by site/build.py; do not edit) -->
67
+ | Skill | What it decides | Reach for it when |
68
+ |---|---|---|
69
+ | [`routing-questions`](skills/routing-questions/SKILL.md) | Whether this becomes work at all, and which skill it becomes | a product, analytics, or experimentation request first arrives, when someone asks for a deep dive or a dashboard, or before opening a query editor on any question about impact, lift, or whether something worked |
70
+ | [`defining-metrics`](skills/defining-metrics/SKILL.md) | A metric turned into a computation, a source of truth, and a statement of how it will be gamed | defining a north-star or guardrail metric, when two dashboards disagree on the same number, when arbitrating between conflicting metric definitions, or when a readout depends on a metric nobody has validated |
71
+ | [`designing-experiments`](skills/designing-experiments/SKILL.md) | The four choices that cannot be repaired after launch, with the MDE from the prior store | planning, powering, or pre-registering an experiment, when deciding whether a question is testable at the available traffic, or when a feature is about to ship without a flag |
72
+ | [`reading-experiments`](skills/reading-experiments/SKILL.md) | Whether the result is a result: SRM, exposure, the sequential bound, CUPED, shrinkage | analysing or reviewing A/B test results, when a test looks like a winner, when someone reports a lift, or when deciding whether to ship on an experiment readout |
73
+ | [`choosing-causal-designs`](skills/choosing-causal-designs/SKILL.md) | The method that matches how assignment happened, and the exit that says there is no comparison group | measuring the impact of something already rolled out, a launch, a migration, a pricing change, or a campaign that reached everyone at once |
74
+ | [`automating-decisions`](skills/automating-decisions/SKILL.md) | Whether a repeated decision belongs to a model, validated out of time, and the holdout that measures its impact | someone asks for a churn, propensity, LTV, scoring, forecasting, uplift, recommendation or allocation model, when a model's offline accuracy is offered as evidence that something worked, or when deciding who gets an offer, a discount or an intervention |
75
+ | [`writing-readouts`](skills/writing-readouts/SKILL.md) | The decision rule first, the result last; the belief filed where the next question starts | a test finishes, when documenting a shipped or killed decision, when writing up a null result or a rollback, or when a question needs an entry someone can find in a year |
76
+ <!-- skills-table:end -->
77
+
78
+ ## What this is not
79
+
80
+ Not an experimentation platform. It does not assign traffic, hold flags, or
81
+ replace your warehouse. It assumes those exist and writes the part that decides
82
+ whether the number they produced is true.
83
+
84
+ ## More
85
+
86
+ The site renders the skills and the two arguments:
87
+ [the map](https://0trm.github.io/gallop/map/) ·
88
+ [the intake](https://0trm.github.io/gallop/intake/) ·
89
+ [the theory layer](https://0trm.github.io/gallop/theory/) ·
90
+ [install](https://0trm.github.io/gallop/install/)
91
+
92
+ ## Licence
93
+
94
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "gallop-pds"
7
+ version = "0.2.0"
8
+ description = "Product data science checks: power from priors, SRM, CUPED, always-valid inference, empirical Bayes shrinkage, out-of-time model validation, and the prior store they read."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "0trm" }]
13
+ dependencies = ["numpy>=1.26", "pandas>=2.0", "scipy>=1.11"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Scientific/Engineering :: Information Analysis",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://0trm.github.io/gallop/"
24
+ Repository = "https://github.com/0trm/gallop"
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=8", "ruff>=0.5"]
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["src"]
31
+
32
+ [tool.setuptools.package-data]
33
+ gallop = ["templates/*.json"]
34
+
35
+ [tool.ruff]
36
+ line-length = 100
37
+ target-version = "py311"
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,17 @@
1
+ """Product data science checks, thin by design.
2
+
3
+ Seven modules, each backing one skill:
4
+
5
+ power MDE, sample size and duration, two-proportion and continuous
6
+ trust sample ratio mismatch and exposure-versus-eligibility
7
+ variance CUPED against a pre-period covariate
8
+ sequential always-valid confidence sequences and group-sequential bounds
9
+ shrink empirical Bayes shrinkage toward the prior store
10
+ priors the prior store and the metric registry on disk
11
+ validate out-of-time validation, lift over the base rate, calibration, Qini, MASE
12
+
13
+ Every function takes and returns arrays or DataFrames, never a database
14
+ connection. Each module runs as a script: python -m gallop.<module> --help
15
+ """
16
+
17
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,89 @@
1
+ """The five-minute path: every check once, against synthetic data.
2
+
3
+ One simulated experiment on a 12% activation rate with a real +0.35pp effect,
4
+ plus a seeded prior store. No configuration, no credentials, no warehouse.
5
+
6
+ Run: python -m gallop.examples.quickstart
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from gallop import power, priors, sequential, shrink, trust, variance
17
+
18
+ RULE = "=" * 74
19
+
20
+
21
+ def main():
22
+ rng = np.random.default_rng(7)
23
+
24
+ # -- the experiment: user-level activation with a pre-period covariate
25
+ n = 40_000
26
+ true_effect = 0.0035
27
+ x = rng.beta(2, 14, 2 * n) # pre-period activation propensity
28
+ arm = np.array(["control"] * n + ["treatment"] * n)
29
+ p_unit = np.clip(x + (arm == "treatment") * true_effect, 0, 1)
30
+ y = rng.binomial(1, p_unit).astype(float)
31
+
32
+ print(RULE)
33
+ print("gallop quickstart: one experiment through every check")
34
+ print(RULE)
35
+
36
+ print("\n1 · Size it before running it (gallop.power)")
37
+ m = power.mde(n, baseline_rate=0.125)
38
+ print(f" at n={n:,} per arm on a 12.5% rate, the MDE is {m * 100:.2f}pp;")
39
+ print(f" detecting {true_effect * 100:.2f}pp instead would need "
40
+ f"{power.sample_size(true_effect, baseline_rate=0.125):,.0f} per arm")
41
+
42
+ print("\n2 · The trust gate (gallop.trust)")
43
+ assigned = {"control": n, "treatment": n - int(rng.integers(0, 120))}
44
+ exposed = {k: int(v * 0.97) for k, v in assigned.items()}
45
+ s = trust.srm(assigned)
46
+ print(f" SRM: chi2 {s['chi2']:.2f} p {s['p']:.3f} -> {s['verdict']}")
47
+ e = trust.exposure_check(assigned, exposed)
48
+ print(f" exposure: pooled rate {e['pooled_rate']:.2%} -> {e['verdict']}")
49
+
50
+ print("\n3 · The effect, with CUPED (gallop.variance)")
51
+ r = variance.cuped(y, x, arm, control="control")
52
+ print(f" raw {r['effect_raw'] * 100:+.3f}pp se {r['se_raw'] * 100:.3f}pp")
53
+ print(f" cuped {r['effect_adjusted'] * 100:+.3f}pp se {r['se_adjusted'] * 100:.3f}pp"
54
+ f" variance reduction {r['variance_reduction']:.0%}")
55
+
56
+ print("\n4 · An interval that survives peeking (gallop.sequential)")
57
+ sd_adj = r["se_adjusted"] * np.sqrt(n / 2)
58
+ av = sequential.always_valid_ci(0.0, r["effect_adjusted"], sd_adj, n, tau2=1e-4)
59
+ lo, hi = av["ci"]
60
+ print(f" always-valid 95% CI [{lo * 100:+.3f}pp, {hi * 100:+.3f}pp]"
61
+ f" boundary |z| {av['bound_z']:.2f} (vs 1.96 fixed)")
62
+ print(f" significant under continuous monitoring: {av['significant']}")
63
+
64
+ print("\n5 · Shrunk toward what this metric has done before (gallop.shrink + priors)")
65
+ with tempfile.TemporaryDirectory() as td:
66
+ store_path = Path(td) / "priors.jsonl"
67
+ past = [0.0009, -0.0004, 0.0021, 0.0013, -0.0011, 0.0028, 0.0006, 0.0016]
68
+ for i, eff in enumerate(past):
69
+ priors.append(store_path, {
70
+ "id": f"2026-{i + 1:02d}-activation-test", "metric": "activation_rate",
71
+ "date": f"2026-{i + 1:02d}-15", "design": "experiment", "effect": eff,
72
+ "unit": "pp", "se": 0.0011, "n_per_arm": 35_000, "decision": "ship" if eff > 0.001 else "no-ship",
73
+ })
74
+ store = priors.read(store_path)
75
+ sh = shrink.from_store(r["effect_adjusted"], r["se_adjusted"], store,
76
+ "activation_rate", "pp")
77
+ print(f" prior from {sh['n_priors']} readouts: mu {sh['mu'] * 100:+.3f}pp"
78
+ f" tau {np.sqrt(sh['tau2']) * 100:.3f}pp")
79
+ print(f" observed {sh['effect'] * 100:+.3f}pp -> shrunk {sh['effect_shrunk'] * 100:+.3f}pp"
80
+ f" (weight on data {sh['weight_on_data']:.2f})")
81
+
82
+ print(f"\n{RULE}")
83
+ print(f"true simulated effect: {true_effect * 100:+.3f}pp. The shrunk estimate is the")
84
+ print("one to write back to the store; the raw one is the winner's curse waiting.")
85
+ print(RULE)
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,118 @@
1
+ """Power, MDE, sample size and duration for a two-arm test.
2
+
3
+ One family of closed forms for a two-sample difference in means. A proportion
4
+ metric is the same formula with sd = sqrt(p(1-p)), so every function takes
5
+ either `sd` (continuous) or `baseline_rate` (proportion), never both.
6
+
7
+ With the defaults (alpha 0.05 two-sided, power 0.80) the MDE reduces to the
8
+ planning rule of thumb 2.8 * sqrt(2 p (1-p) / n).
9
+
10
+ The closed form assumes the analysis unit is the randomisation unit, and it
11
+ lies for ratio metrics, heavy tails and capped metrics. Sizing from it when
12
+ those assumptions fail is a design question, not an arithmetic one; see the
13
+ designing-experiments skill.
14
+
15
+ Run: python -m gallop.power mde --n 5000 --baseline-rate 0.10
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+
22
+ import numpy as np
23
+ from scipy import stats
24
+
25
+ # %% ------------------------------------------------------------- closed forms
26
+
27
+
28
+ def _sd(sd: float | None, baseline_rate: float | None) -> float:
29
+ if (sd is None) == (baseline_rate is None):
30
+ raise ValueError("give exactly one of sd (continuous) or baseline_rate (proportion)")
31
+ if baseline_rate is not None:
32
+ if not 0 < baseline_rate < 1:
33
+ raise ValueError("baseline_rate must be strictly between 0 and 1")
34
+ return float(np.sqrt(baseline_rate * (1 - baseline_rate)))
35
+ return float(sd)
36
+
37
+
38
+ def mde(n_per_arm, *, sd=None, baseline_rate=None, power=0.80, alpha=0.05):
39
+ """Smallest absolute effect detectable at the stated power. Same unit as the metric."""
40
+ s = _sd(sd, baseline_rate)
41
+ z_alpha = stats.norm.ppf(1 - alpha / 2)
42
+ z_beta = stats.norm.ppf(power)
43
+ return float((z_alpha + z_beta) * s * np.sqrt(2.0 / n_per_arm))
44
+
45
+
46
+ def sample_size(effect, *, sd=None, baseline_rate=None, power=0.80, alpha=0.05):
47
+ """Units per arm needed to detect an absolute `effect`. Inverse of mde()."""
48
+ s = _sd(sd, baseline_rate)
49
+ z_alpha = stats.norm.ppf(1 - alpha / 2)
50
+ z_beta = stats.norm.ppf(power)
51
+ return float(2 * ((z_alpha + z_beta) * s / abs(effect)) ** 2)
52
+
53
+
54
+ def power_at(n_per_arm, effect, *, sd=None, baseline_rate=None, alpha=0.05):
55
+ """Power of a two-sided test at the given n and absolute effect."""
56
+ s = _sd(sd, baseline_rate)
57
+ se = s * np.sqrt(2.0 / n_per_arm)
58
+ z_alpha = stats.norm.ppf(1 - alpha / 2)
59
+ z = abs(effect) / se
60
+ return float(stats.norm.sf(z_alpha - z) + stats.norm.cdf(-z_alpha - z))
61
+
62
+
63
+ def duration(effect, units_per_day, *, sd=None, baseline_rate=None, power=0.80,
64
+ alpha=0.05, arms=2, eligible_share=1.0):
65
+ """Days to reach the required sample, given daily eligible traffic.
66
+
67
+ `units_per_day` is total new units entering the experiment surface per day;
68
+ `eligible_share` is the fraction that actually enters assignment.
69
+ """
70
+ n = sample_size(effect, sd=sd, baseline_rate=baseline_rate, power=power, alpha=alpha)
71
+ daily = units_per_day * eligible_share
72
+ if daily <= 0:
73
+ raise ValueError("units_per_day * eligible_share must be positive")
74
+ return float(arms * n / daily)
75
+
76
+
77
+ # %% --------------------------------------------------------------------- cli
78
+
79
+
80
+ def main(argv=None):
81
+ p = argparse.ArgumentParser(prog="gallop.power", description=__doc__.splitlines()[0])
82
+ sub = p.add_subparsers(dest="cmd", required=True)
83
+
84
+ def common(sp, effect=False, n=False):
85
+ if effect:
86
+ sp.add_argument("--effect", type=float, required=True, help="absolute effect")
87
+ if n:
88
+ sp.add_argument("--n", type=float, required=True, help="units per arm")
89
+ sp.add_argument("--sd", type=float)
90
+ sp.add_argument("--baseline-rate", type=float)
91
+ sp.add_argument("--power", type=float, default=0.80)
92
+ sp.add_argument("--alpha", type=float, default=0.05)
93
+
94
+ common(sub.add_parser("mde", help="smallest detectable absolute effect"), n=True)
95
+ common(sub.add_parser("n", help="units per arm for an effect"), effect=True)
96
+ sp = sub.add_parser("duration", help="days to reach the required sample")
97
+ common(sp, effect=True)
98
+ sp.add_argument("--units-per-day", type=float, required=True)
99
+ sp.add_argument("--eligible-share", type=float, default=1.0)
100
+ sp.add_argument("--arms", type=int, default=2)
101
+ common(sub.add_parser("power", help="power at a given n and effect"), effect=True, n=True)
102
+
103
+ a = p.parse_args(argv)
104
+ kw = {"sd": a.sd, "baseline_rate": a.baseline_rate, "alpha": a.alpha}
105
+ if a.cmd == "mde":
106
+ print(f"mde (absolute): {mde(a.n, power=a.power, **kw):.6f}")
107
+ elif a.cmd == "n":
108
+ print(f"n per arm: {sample_size(a.effect, power=a.power, **kw):,.0f}")
109
+ elif a.cmd == "duration":
110
+ d = duration(a.effect, a.units_per_day, power=a.power, arms=a.arms,
111
+ eligible_share=a.eligible_share, **kw)
112
+ print(f"days: {d:,.1f}")
113
+ elif a.cmd == "power":
114
+ print(f"power: {power_at(a.n, a.effect, **kw):.4f}")
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
@@ -0,0 +1,160 @@
1
+ """The prior store and the metric registry on disk.
2
+
3
+ Both are append-friendly JSONL, one JSON object per line, validated on write
4
+ against the schemas in gallop/templates/. JSONL because the store must be
5
+ reviewable in a pull request; a store nobody can diff goes stale, which is the
6
+ failure mode the theory layer exists to prevent.
7
+
8
+ The store is a log, windowed on read. A correction is a new record carrying
9
+ `supersedes`, never an edit. `expires_on` names the event that would
10
+ invalidate the entry rather than a date somebody has to remember.
11
+
12
+ Validation is done here in about thirty lines (required keys, types, enums,
13
+ no unknown keys) rather than by the jsonschema package, so the runtime
14
+ dependencies stay at numpy, pandas, scipy. The schema files remain the
15
+ published contract for anything else that writes to the store.
16
+
17
+ Run: python -m gallop.priors read --store priors.jsonl --metric activation_rate
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ from importlib import resources
26
+ from pathlib import Path
27
+
28
+ import pandas as pd
29
+
30
+ # %% -------------------------------------------------------------- validation
31
+
32
+ _TYPES = {"string": str, "number": (int, float), "integer": int, "object": dict}
33
+
34
+
35
+ def _schema(name):
36
+ with resources.files("gallop.templates").joinpath(name).open() as f:
37
+ return json.load(f)
38
+
39
+
40
+ def validate(record, schema_name="prior-store.schema.json"):
41
+ """Check a dict against one of the packaged schemas. Raises ValueError."""
42
+ sch = _schema(schema_name)
43
+ props = sch["properties"]
44
+ errors = []
45
+ for key in sch["required"]:
46
+ if key not in record:
47
+ errors.append(f"missing required field {key!r}")
48
+ for key, value in record.items():
49
+ if key not in props:
50
+ errors.append(f"unknown field {key!r}")
51
+ continue
52
+ spec = props[key]
53
+ expected = _TYPES[spec["type"]]
54
+ if not isinstance(value, expected) or isinstance(value, bool):
55
+ errors.append(f"{key!r} must be {spec['type']}, got {type(value).__name__}")
56
+ continue
57
+ if "enum" in spec and value not in spec["enum"]:
58
+ errors.append(f"{key!r} must be one of {spec['enum']}, got {value!r}")
59
+ if "pattern" in spec and not re.fullmatch(spec["pattern"], value):
60
+ errors.append(f"{key!r} must match {spec['pattern']}, got {value!r}")
61
+ if errors:
62
+ raise ValueError(f"invalid {sch['title']}: " + "; ".join(errors))
63
+ return record
64
+
65
+
66
+ # %% ------------------------------------------------------------- prior store
67
+
68
+
69
+ def append(path, record):
70
+ """Validate a record and append it to the store. Returns the record."""
71
+ validate(record, "prior-store.schema.json")
72
+ p = Path(path)
73
+ existing = {r["id"] for _, r in _iter_records(p)} if p.exists() else set()
74
+ if record["id"] in existing:
75
+ raise ValueError(f"id {record['id']!r} already in {path}; corrections use supersedes")
76
+ with p.open("a") as f:
77
+ f.write(json.dumps(record, ensure_ascii=False) + "\n")
78
+ return record
79
+
80
+
81
+ def _iter_records(p):
82
+ with Path(p).open() as f:
83
+ for i, line in enumerate(f, 1):
84
+ if line.strip():
85
+ yield i, json.loads(line)
86
+
87
+
88
+ def read(path, metric=None, window=100):
89
+ """The store as a DataFrame: superseded records dropped, windowed on read.
90
+
91
+ `window` keeps the most recent records per metric, by date then file
92
+ order. Every record is validated; a malformed line fails loudly with its
93
+ line number rather than flowing into a shrinkage estimate.
94
+ """
95
+ rows = []
96
+ for i, rec in _iter_records(path):
97
+ try:
98
+ validate(rec, "prior-store.schema.json")
99
+ except ValueError as e:
100
+ raise ValueError(f"{path}:{i}: {e}") from None
101
+ rows.append(rec)
102
+ df = pd.DataFrame(rows)
103
+ if df.empty:
104
+ return df
105
+ superseded = set(df["supersedes"].dropna()) if "supersedes" in df.columns else set()
106
+ df = df[~df["id"].isin(superseded)]
107
+ if metric is not None:
108
+ df = df[df["metric"] == metric]
109
+ df = df.assign(_order=range(len(df))).sort_values(["metric", "date", "_order"])
110
+ df = df.groupby("metric", group_keys=False).tail(window).drop(columns="_order")
111
+ return df.reset_index(drop=True)
112
+
113
+
114
+ # %% ---------------------------------------------------------------- registry
115
+
116
+
117
+ def read_registry(path, status=None):
118
+ """The metric registry as a DataFrame, optionally filtered by status."""
119
+ rows = []
120
+ for i, rec in _iter_records(path):
121
+ try:
122
+ validate(rec, "metric-registry.schema.json")
123
+ except ValueError as e:
124
+ raise ValueError(f"{path}:{i}: {e}") from None
125
+ rows.append(rec)
126
+ df = pd.DataFrame(rows)
127
+ if status is not None and not df.empty:
128
+ df = df[df["status"] == status].reset_index(drop=True)
129
+ return df
130
+
131
+
132
+ # %% --------------------------------------------------------------------- cli
133
+
134
+
135
+ def main(argv=None):
136
+ p = argparse.ArgumentParser(prog="gallop.priors", description=__doc__.splitlines()[0])
137
+ sub = p.add_subparsers(dest="cmd", required=True)
138
+ sp = sub.add_parser("append", help="validate and append one record")
139
+ sp.add_argument("--store", required=True)
140
+ sp.add_argument("--json", required=True, help="the record as a JSON object")
141
+ sp = sub.add_parser("read", help="print the windowed store")
142
+ sp.add_argument("--store", required=True)
143
+ sp.add_argument("--metric")
144
+ sp.add_argument("--window", type=int, default=100)
145
+ sp = sub.add_parser("registry", help="print the metric registry")
146
+ sp.add_argument("--registry", required=True)
147
+ sp.add_argument("--status")
148
+
149
+ a = p.parse_args(argv)
150
+ if a.cmd == "append":
151
+ rec = append(a.store, json.loads(a.json))
152
+ print(f"appended {rec['id']} to {a.store}")
153
+ elif a.cmd == "read":
154
+ print(read(a.store, metric=a.metric, window=a.window).to_string(index=False))
155
+ else:
156
+ print(read_registry(a.registry, status=a.status).to_string(index=False))
157
+
158
+
159
+ if __name__ == "__main__":
160
+ main()