skiftet_statistical 0.1.0 → 0.4.0

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +346 -2
  3. data/README.md +1618 -22
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
  18. data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
  19. data/lib/skiftet_statistical/bandit.rb +3 -3
  20. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  21. data/lib/skiftet_statistical/beta_function.rb +153 -0
  22. data/lib/skiftet_statistical/cholesky.rb +244 -0
  23. data/lib/skiftet_statistical/cusum.rb +183 -0
  24. data/lib/skiftet_statistical/descriptive.rb +3 -3
  25. data/lib/skiftet_statistical/gamma.rb +97 -0
  26. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  27. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  28. data/lib/skiftet_statistical/inputs.rb +214 -0
  29. data/lib/skiftet_statistical/joint_beta.rb +63 -0
  30. data/lib/skiftet_statistical/linear_value.rb +364 -0
  31. data/lib/skiftet_statistical/numbers.rb +98 -0
  32. data/lib/skiftet_statistical/prequential.rb +206 -0
  33. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  34. data/lib/skiftet_statistical/significance.rb +65 -5
  35. data/lib/skiftet_statistical/version.rb +1 -1
  36. data/lib/skiftet_statistical.rb +57 -5
  37. metadata +43 -11
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b72f7bccf09703650bb6232fef2bf588bc41564bf10ecc4a3802d9a432ecaee8
4
- data.tar.gz: 9bb85de398e7bd00633db142d08122cdc3b55fda9320ef5035eab53b04051883
3
+ metadata.gz: 8324b9625b6fd9bc14c83e0012409543d0de68b2dd876187f76c5124afe800c6
4
+ data.tar.gz: a4feb3dd72cce8563ff41c8b3e08aecdbab2c69ee8429a9e1592896b2e9f85cb
5
5
  SHA512:
6
- metadata.gz: 17c6847d2e87c057b4f1a22901f6b9d542a1576af7edf0cb261c7962542113359e63ebb69a1ec408d2f80937d3dfc13ecd3c11f12c24841d277f11b47487195a
7
- data.tar.gz: 0e37add790389b5362df68bbf9c71e7e08b8ec967a0af6a615bb2958a4bbcde67ba1e0766cb52bb1a8560a4001b61cf9a7429e9d10578c613cd285e5e4a79107
6
+ metadata.gz: 23fa458b6316cc5baa296ce0e33e556e0eb7b8552c939e3ac9e4dd9ffa27b24ecca44af4458403ca26c054b998a1074f5899a977ee7319c41e8c8c24a8feb00f
7
+ data.tar.gz: 33cd6f0ba9b919182cdf445a41e24a3b3be45ce98356eb7c616c5f7ef6b9f9e73b211ed486e99169427d5a7083fe47b9356c289a058961c7d87cd0119e22d1fe
data/CHANGELOG.md CHANGED
@@ -1,14 +1,358 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - unreleased
4
+
5
+ The generic bandit: one posterior set of items, ordered and picked by one rule
6
+ from a key, given uniforms or an RNG, with the odds of what that rule draws
7
+ (exact within the sizes stated below, otherwise a seeded Monte Carlo estimate
8
+ with its standard error) and the summaries an allocation reads. The caller
9
+ hands it items, views and outcomes (yes/no or amounts) and decides what counts
10
+ as each. Pure Ruby, no new dependency, Ruby >= 3.1 as before. 0.3.0 was never
11
+ released, so its additions (its entry below) ship in this version. Every 0.3.0
12
+ module answers as 0.3.0 did, for every input, and every 0.3.0 spec and pin
13
+ passes unedited.
14
+
15
+ - `Bandit::Posteriors`, the posterior set. `yes_no(items, priors: nil)`: Beta
16
+ posteriors from tallies `{ item => { views:, outcomes: } }`, optionally with
17
+ inverse-propensity sums (`weighted_views`, `weighted_outcomes`,
18
+ `weighted_views_squared`) rescaled to the Kish size; priors nil (Beta(1, 1)),
19
+ one pair or one pair an item. `amounts(items, priors:)`: Normal posteriors
20
+ for the mean per view from `views`, `outcomes` (the sum of amounts) and
21
+ `outcome_squares`, under a `Prior::Amount` (or one an item). `beta(pairs,
22
+ views: nil)` and `normal(pairs, views: nil)` take pairs as they are. A set
23
+ answers `items`, `kind`, `parameters`, `views`, `values(rng:)` (one draw an
24
+ item) and `greedy(may_lead: nil)` (the first largest mean). Items are
25
+ non-blank Strings, any number of them, none included, in input order, which
26
+ is the tie order everywhere; a Symbol, a blank or repeated name, a name
27
+ holding a NUL byte or an earlier name's bytes is refused when the set is
28
+ built. `EXACT_PICK_MAX` (20), `EXACT_ORDER_MAX` (10), `LARGEST_TOTAL` (1e6).
29
+ - One pair everywhere: a Beta pair above `LARGEST_TOTAL` is scaled to it, its
30
+ mean kept, in `parameters`, the one pair every draw (`rank`, `pick` and
31
+ `values`, from any source), every odds call (exact or Monte Carlo),
32
+ `summary`'s P(best) and loss and `greedy` read; the pairs as given feed
33
+ `summary`'s means and intervals alone. A scaled pair never sums above
34
+ `LARGEST_TOTAL`, so a set built from another set's `parameters` keeps them
35
+ bit for bit. A Beta parameter above 0 but below
36
+ `BetaArgmax::SMALLEST_PARAMETER` (1e-6) builds; each computation that cannot
37
+ read it (the quantile draws, the odds, `summary`) refuses it, and the RNG
38
+ draws take it.
39
+ - The order rule, written once: `rank(key:, uniforms:, rng:, shuffle: 0.0,
40
+ explore: 0.0, may_lead: nil, min_views: 0.0, pinned: {}, top: nil)` answers
41
+ an `Order` (`items`, `leader`, `branch`, `draws`), and `pick` (`rank` with
42
+ top 1 and no pins) a `Choice` (`item`, `head`, `branch`, `draws`), both
43
+ frozen keyword Structs. The branch is `:shuffled` (by priority), `:explored`
44
+ (an eligible item of smallest priority leads, the rest by draw) or
45
+ `:thompson` (by draw); pinned items keep their positions and draw nothing;
46
+ the leader is the head when it has at least `min_views` views. Exactly one
47
+ source: a key, whose uniforms are `Draw.uniform_for(*key, "explore")` and,
48
+ per item, `(*key, "priority", item)` and `(*key, "draw", item)`, so a key
49
+ draws the same order in every process and no item's uniforms depend on
50
+ another item;
51
+ given uniforms; or an RNG (one branch value, the priorities, one `Sampler`
52
+ draw a free item, nothing else from the stream). A key or given uniforms
53
+ draw by quantile (`BetaArgmax.position`, or mean + sd times the Normal
54
+ quantile on the open grid), an RNG by `Sampler`; each draws exactly from the
55
+ posteriors, so one set of odds covers all three. Over 1 000 keys with
56
+ `shuffle: 0.1` and no exclusion or pin, an added item never changed the
57
+ others' relative order (with an item pinned at position 1 it did on 352).
58
+ Refused, each an `ArgumentError` before any draw and before anything is
59
+ taken from an RNG: a rule outside its bounds, a pinned position or name that
60
+ is not an item, a name pinned twice, a `may_lead` name that is not an item,
61
+ `top` below 1, no source or more than one, a malformed `uniforms` Hash, and
62
+ under a key or given uniforms a Beta parameter below 1e-6. An RNG's own
63
+ answer is checked as it comes: one that is not a Float in [0, 1), or a raise
64
+ from its `rand`, is an `ArgumentError` at that answer, after the draws taken
65
+ before it.
66
+ - `Order#repin(pinned)`: `rank(pinned:)` again on an order's own quantile draws.
67
+ - Exact Normal keys: the order rule compares two Normal draws, from every
68
+ source, by the exact sign of (mean_i - mean_j) + (sd_i z_i - sd_j z_j), each
69
+ mean + sd * z formed exactly, never rounded, so two draws never tie or
70
+ misorder where a standard deviation lies below a rounding of its mean, and
71
+ the odds are the probability of what the rule draws; `draws` answers the
72
+ rounded values.
73
+ - The odds of what the rule draws: `pick_probabilities`, `order_probability`,
74
+ `positions` and `order_summary`, with `Choice#probability` and
75
+ `Order#probability`. They are exact, by quadrature, for a pick among up to
76
+ `EXACT_PICK_MAX` (20) contenders and for orders and positions over up to
77
+ `EXACT_ORDER_MAX` (10) free items with none barred from leading; the orders
78
+ and positions sum on every item's quantiles at 400 levels an item (an
79
+ internal grid, not a public constant), each sum on points where its own
80
+ item's points keep their digits (Betas share one grid in the position on a
81
+ log scale the rule compares; each Normal sums on its own points in its own
82
+ standardised variable, the other items read there), extrapolated from the
83
+ points and their every other level, within 4e-6 of closed forms. Otherwise
84
+ they are one of two stated Monte Carlo forms on `Random.new(seed:)`: the
85
+ pick's share of draws in which each contender is the largest, and the rule's
86
+ own visits for orders and positions, each drawing on the scaled pairs the
87
+ exact odds read. Both forms refuse a Beta parameter below 1e-6 at every
88
+ size, as a keyed draw does.
89
+ - `Posteriors.mixture(shares:, probabilities:)`: the probability that a
90
+ two-layer draw shows each item, the sum over branches of a branch's share
91
+ times its odds.
92
+ - `summary(credible_mass: 0.95, among: nil)`: per item its views, mean and
93
+ equal-tailed interval on the pair as given (`BetaFunction.quantile`,
94
+ accurate to about 2e-10 up to totals near 1e8; beyond about 3e8 one bound
95
+ can fall on or past the mean), and for the items `among` names (at most 20)
96
+ the exact P(best) and expected loss (never below 0), the shape
97
+ `Allocator.from_summary` takes; `among: []` is a means-only summary for a set
98
+ of any size, and `means` answers those means alone (`{ item => mean }`,
99
+ frozen, bit for bit summary's), with no interval and no quantile call.
100
+ - `Bandit::Prior`: `flat`, `given(mean:, strength:)`, `amount(mean:,
101
+ variance:, strength:)` (a frozen `Prior::Amount`), `borrowed(group:, own:)`
102
+ (the one leave-one-out rule), `pooled`, `pooled_new`, `pooled_mean`,
103
+ `tilts(multipliers, clip:)`, `chain(levels:, root_mean:, tilt:, clip:)` and
104
+ `pooled_amount`, which refuses a group with nothing left out or no variance
105
+ (the caller then states its own scale with `amount`).
106
+ - `Bandit::Decay`: `weight(age, half_life:, cap: nil)`, 2 ** (-age /
107
+ half_life) and 0.0 past the cap, and `fold(rows, half_life:, cap: nil)`,
108
+ every sum of the rows weighted.
109
+ - `Bandit::Draw`: `uniform(text)` (the first 8 bytes of SHA256, on the 2**53
110
+ grid), `uniform_for(*parts)`, `seed_for(*parts)`, `weighted(weights,
111
+ uniform)` (the one draw by weight, `[key, share]`; a weight that is not a
112
+ finite number above 0 counts 0), `from_weights` (its key) and
113
+ `open_uniform(uniform)` (clamped to [2**-53, 1 - 2**-53], the one reading of
114
+ a quantile draw's uniform).
115
+ - `Bandit::NormalArgmax`: `probabilities(params, epsilon: 0.0)` and
116
+ `expected_maximum(params)`, BetaArgmax's rule on Normals. Each member is
117
+ integrated in its own standardised variable, so a narrow member far from
118
+ the others is weighed on its own scale; each answer lies within
119
+ 2 k (k + 1) * 1e-10 + 2e-12 of the exact probability for k members.
120
+ - `Bandit::Pass.call(status:, hold:, crown: true, seed: Random.new_seed)`: one
121
+ allocation pass with a hold on its win. The block is yielded (false,
122
+ `Random.new(seed)`), then, only when `crown` and the status is "running" and
123
+ the hold is satisfied for the probe's leader, again with (true, a fresh
124
+ `Random.new(seed)`), so the probe and the crown read the same draws; the
125
+ answer is the last block's Hash plus `ready:`.
126
+ - `BetaArgmax.position(uniform, alpha, beta)`, `distribution(x, alpha, beta)`
127
+ and `expected_maximum(params)`, beside `probabilities`, `pick` and `greedy`,
128
+ which are unchanged (`pick` compares its own 0.3.0 position). `position` is a
129
+ guarded Newton step on the same distribution function. Against 0.3.0's
130
+ bisection, recorded on 30 members at 1 000 uniforms each, the spec bounds
131
+ |position - recorded| / max(1, |recorded|), an absolute error where the
132
+ recorded position lies within 1 of 0 and a relative one beyond: 1e-12
133
+ (measured at most 4.7e-13), 1e-9 above alpha + beta 1e4 (measured 3.0e-13)
134
+ and 1e-8 at the smallest parameter (measured 9.5e-11).
135
+ - `Policies::ThompsonSampling` and `Policies::Softmax` are kept as 0.3.0 wrote
136
+ them, as are `EpsilonGreedy`, `UCB1`, `Sampler` and every other 0.3.0 module:
137
+ each answers as in 0.3.0 for every input, the same value or the same error,
138
+ and the same use of the rng. New callers draw through `Bandit::Posteriors`.
139
+ - Every number the code new in 0.4.0 reads lies in one input domain: a real
140
+ number whose Float is finite and at most `SkiftetStatistical::Numbers::LARGEST`
141
+ (1e100) either side of 0. Anything else, a larger number included, is an
142
+ `ArgumentError` where it is read, and no product the new code forms
143
+ multiplies more than two numbers of that size, so no sum or product of
144
+ inputs leaves a Float; every answer of the new bandit is checked finite
145
+ before it is returned, and a Normal quantile draw reads its uniform on
146
+ [2**-53, 1 - 2**-53].
147
+ - A caller's object whose method raises where the new code calls it (a key
148
+ part's `to_s`, a name's `hash` or `eql?`, a list's `each`, an rng's `rand`),
149
+ with a `StandardError` or a `NotImplementedError`, is an `ArgumentError`
150
+ naming the input; a caller's String, Hash or Array is read by its contents,
151
+ never by its own methods, so a missing key reads as absent whatever default
152
+ the Hash holds, and a refusal names a class as Ruby records it. A list is
153
+ any Enumerable but a Hash, read once by its `each`.
154
+ - The moments of amounts, the amounts posterior's mean and every shrunk mean
155
+ (`Prior.pooled_mean`, `pooled`, `pooled_new`, `chain`) are formed exactly
156
+ from the Floats given and rounded once to the nearest Float, and the
157
+ posterior's standard deviation from those moments, so sums that cancel and
158
+ products that would underflow change no answer. A caller's Hash is read by
159
+ its keys as names (items, or a tally's sums): one that compares its keys by
160
+ identity reads as a plain one, and two keys of one name are refused.
161
+ - Two new simulations, each asserting its bounds, every figure in the README,
162
+ "Bandit simulations: measured results". The order rule at small-site
163
+ traffic (`order_simulation_spec.rb`, 13 cells of 50 runs, 28 days each):
164
+ with five items at close rates (5 down to 3 %) and 300, 1 000 and 3 000
165
+ visits a day, `rank(rng:, shuffle: 0.10)` lost 2.4, 1.6 and 1.1 % of the
166
+ best order's expected outcomes, where a fixed random order lost 8.0, 7.4 and
167
+ 7.4 %, and put the best item on top on day 28 in 88, 100 and 100 % of runs;
168
+ on amounts (4 down to 1.5 %) it lost 4.3, 2.6 and 1.9 % (a fixed order 12.6
169
+ to 13.8 %); with ten items at 3 000 a day 2.2 % (19.6 %); every cell's
170
+ random share 0.099 to 0.1003 of its visits. The odds against the rule's own
171
+ visits (`odds_simulation_spec.rb`, 2 * 10^5 visits on five fixtures): every
172
+ head and position within 2.75 standard errors of its exact value, the orders'
173
+ chi-square p-values 0.12 to 0.88; P(best) and expected loss within 1.61 and
174
+ 1.44 standard errors of 10^6 joint draws.
175
+ - The simulations draw by weight through `Draw.from_weights` (the four copies
176
+ of that draw are gone), and no count moved: 12,263,200 draws compared, 0
177
+ different. Block (j) of the two-layer simulation draws through the posterior
178
+ set (`pick_probabilities`, `greedy`, `from_weights`, `mixture`), its figures
179
+ unchanged (0.5807 and 0.4193; as they ran, 0.6273 and 0.3727). Every count
180
+ and figure of 0.3.0's entry, measured again on 0.4.0, came out the same, its
181
+ run time aside (below).
182
+ - The ten simulation files took 2,013 seconds with YJIT on one development
183
+ machine while other work kept it busy (a load average of 31 to 46), the
184
+ order file 667 of them (263 to 407 in other runs), each figure the sum of
185
+ the examples' own run times, boot and load left out, as 0.3.0's figures
186
+ are; CI's simulations job's timeout goes from 30 to 45 minutes.
187
+ - Costs, measured as CPU time with YJIT: a keyed `rank` of 8 Beta items 0.21
188
+ ms (totals up to 1e3) and 0.54 ms (up to 1e5), of 8 Normals 0.08 ms; an RNG
189
+ `rank` of 8 items 0.017 ms; `BetaArgmax.position` 0.02 to 0.06 ms from
190
+ Beta(3, 300) to Beta(4 000, 96 000), where 0.3.0's bisection took 0.11 to
191
+ 0.92 ms in the same run; exact odds and summaries 2 to 560 ms
192
+ a call (1.2 s for the positions of 10 items with exploration), so a caller
193
+ computes them off the request path, once a pass, never once a visit.
194
+ - Docs in generic words; the gemspec's summary and description cover the
195
+ whole toolkit, and its metadata names the source once (`homepage_uri` gone,
196
+ `spec.homepage` kept), so the gem builds with no warning.
197
+
198
+ ## [0.3.0] - not released; its additions ship in 0.4.0
199
+
200
+ Allocating on a value of several parts, pooling across cells and groups,
201
+ hierarchical rates, an exact Beta argmax, a prequential score and a change
202
+ test, with per-arm floors and fixed shares in the allocator and one phase rule
203
+ for any posterior. Pure Ruby, no new dependency, Ruby >= 3.1 as before. Every
204
+ 0.2.0 call answers as before, bit for bit (the same signatures, defaults,
205
+ arithmetic order and draw order), and every 0.2.0 spec passes unedited.
206
+
207
+ - `Bandit::Allocator.call` gains `arm_floors:` (`{ arm => floor }`, each in
208
+ (0, 1], in place of `Config#floor`) and `fixed_shares:` (`{ arm => share }`,
209
+ each in (0, 1), held exactly in every phase), both nil by default. One lift
210
+ serves every phase; the free arms share `1 - F` by P(best) taken among
211
+ themselves; a fixed arm's `p_best` and `expected_loss` are nil, and a fixed
212
+ arm is never the won arm. Refused with an `ArgumentError` before any draw: a
213
+ key that is not an arm, an arm in both, floors and shares summing above 1;
214
+ every arm fixed raises `FixedAllocation`.
215
+ - `Bandit::Allocator.from_summary(arms:, summary:, exposures:, status:,
216
+ winner_arm:, previous_weights: nil, hold_satisfied: true, config: Config.new,
217
+ fixed_shares: nil, arm_floors: nil)` — the same paused / won / warm-up /
218
+ automatic-win / shifting rule on statistics computed elsewhere (`summary`
219
+ `{ arm => { p_best:, expected_loss:, mean:, lower:, upper: } }`, or nil to
220
+ hold warm-up), answering `call`'s shape. `call` runs on the same private rule,
221
+ so the two cannot disagree on a phase.
222
+ - `Bandit::Allocator.lift(weights, config: Config.new)` — 0.2.0's private
223
+ `with_floor`, step for step, made public; the private method stays as a
224
+ delegate.
225
+ - `SkiftetStatistical::LinearValue` — `summary(arms:, parts:, means:,
226
+ covariance:, parameters:, weights:, draws:, rng:, credible_mass: 0.95,
227
+ rounding: 0.0)` (per arm `point`, `mean`, `lower`, `upper`, `p_best`,
228
+ `expected_loss`) and `each_draw(...)` (each draw's values, the rows `summary`
229
+ is built on): P(best) of a linear value of several rates, one joint normal
230
+ over every (arm, part) pair, with weights drawn from `fixed`, `uniform` or
231
+ clipped `normal` parameters in every draw. Up to 7 arms and 12 parts
232
+ (`MAX_ARMS`, `MAX_PARTS`). The draw order is part of the API.
233
+ - `SkiftetStatistical::Cholesky` — `factor(rows)`, the lower-triangular factor
234
+ (a pivot with no variance set to 0 with its column), and `root(rows,
235
+ rounding: 0.0)`, the same arithmetic largest pivot first, for a matrix of
236
+ lower rank. No matrix library.
237
+ - `SkiftetStatistical::SharedCredit` — `estimate(rows, arms:, parts:)`: Hájek
238
+ means per (arm, part) and their sandwich covariance when one row may credit
239
+ several arms (an ordinary test credits only the arm drawn, 1 / p), with
240
+ Kish's effective size; `accumulator(arms:, parts:)` (`Accumulator#add`,
241
+ `#merge`, `#to_h`, `.from_h`, `.from_sums`, `#estimate`, `#arms`, `#parts`), the
242
+ sufficient sums folded once and stored as JSON; `from_sums(sums, arms:, parts:)`, the same estimate from
243
+ per-arm sums; `linear(estimate, weights:)` and `contrast(estimate, a, b,
244
+ weights:)` at fixed weights; `contrast_for_fit(estimate, arm, reference,
245
+ weights:, minimum_effective: 20)`, a contrast with one per-visit variance
246
+ pooled over its two arms, nil under the Kish minimum. An estimate
247
+ (`SharedCredit::Estimate`) answers `#parts` and `#rounding`; a covariance
248
+ entry within its cancellation rounding answers exactly 0.0.
249
+ - `SkiftetStatistical::HierarchicalRates` — `leave_one_out_share`, `node`
250
+ (a node's ratio, hot probability and tail under a Gamma-Poisson or two-groups
251
+ prior), `fit_strength` (a level's strength and hot share on `GRID`, 57
252
+ strengths from 2^-2 to 2^12, with its profile interval), `fit_leaves` (the
253
+ leaf level's fit, each parent's null rate estimated jointly),
254
+ `smooth_strength`, `marshall` and `boundary_test`. Counts may be real numbers.
255
+ - `SkiftetStatistical::AdditiveContrasts` — `fit(cells:, arms:, sweeps: 5,
256
+ variances: nil)`: the arms' contrasts against a reference arm pooled along a
257
+ nested tree and crossed groupings, the variances by moments or held fixed,
258
+ answering an `AdditiveContrasts::Fit` whose `#variation_tests` gives each
259
+ variance's Cochran's Q, degrees and p; `baseline(rows:, effects:)`, each
260
+ cell's level from all its visits, adjusted on a covariate; `pair(arm, part)`,
261
+ the name of a pooled (arm, part); `pooled_estimate(own:, effects:, baselines:,
262
+ cell:)`, a cell's pooled estimate in `SharedCredit`'s shape, its level shared
263
+ between the arms and each arm's effect its own.
264
+ - `SkiftetStatistical::BetaArgmax` — `probabilities(params, epsilon: 0.0)`,
265
+ the exact probability that each of up to 20 Betas is the largest (composite
266
+ Gauss-Legendre, exact to 1e-8), `pick(params, explore:, priorities:,
267
+ uniforms:, epsilon:)`, the seedless pick those probabilities describe, and
268
+ `greedy(params)`; `TAIL`, `BREAKS`, `NODES`, `MAX_MEMBERS`,
269
+ `SMALLEST_PARAMETER`, `LARGEST_TOTAL`.
270
+ - `SkiftetStatistical::GaussLegendre.nodes(n)` — the n-point rule on [-1, 1],
271
+ computed once per n.
272
+ - `BetaFunction.log_density(x, alpha, beta)`; `regularized` and `quantile` are
273
+ unchanged.
274
+ - `SkiftetStatistical::Prequential` — `score(rows, depth:)`, `gains(rows,
275
+ depths:)` (the bits per event a deeper predictor saves, with a cluster-robust
276
+ standard error) and `clustered_mean(sums:, counts:, level: 0.95)`.
277
+ - `SkiftetStatistical::Cusum` — `run(z, reference:, threshold:)`,
278
+ `average_run_length(shift:, reference:, threshold:)` (Siegmund's
279
+ approximation), `threshold(reference:, false_alarm_steps:)` and
280
+ `minimum_detectable(reference:, threshold:, horizon_steps:)`.
281
+ - Every new module reads numbers by one check (a finite real number, finite as
282
+ a Float too), takes `draws:` as an Integer >= 0, never converted, and takes
283
+ every random input as an injected `rng:` or given uniforms.
284
+ - `spec/pins/` pins the new outputs (1e-12 relative, 1e-9 through log-gamma, a
285
+ pinned 0.0, 1.0 or infinity exact).
286
+ - Six new simulations in `spec/simulations` (`SIMULATIONS=1`), each asserting
287
+ its bounds, every figure in the README, "Bandit simulations: measured
288
+ results". Among them: no share below its floor or off its fixed share in
289
+ 2,800 passes through every phase; nothing crowned in 2,000 passes at
290
+ `hold_satisfied: false`; values arriving up to 23 passes late leaving a final
291
+ bias within 1.08 standard errors; 95 % intervals covering the truth in 94.25
292
+ to 96.25 % of 400 runs; across 40 pooled cells whose levels differ, the
293
+ reference arm's mean share spreading 1.007 times the equal-level noise floor's
294
+ at a rank correlation of -0.073; every level's strength within a factor of 2
295
+ in 20 of 20 runs; the argmax within 2.44 standard deviations of 10^4 picks and
296
+ of 10^6 Beta draws on 30 fixtures; the change test's false alarms 0.983 and
297
+ 0.260 per 365 steps against a stated 1 and 0.25. All eight simulation files
298
+ take about 14 minutes with YJIT on one development machine; CI's simulations
299
+ job now sets `RUBY_YJIT_ENABLE`, and its path list holds every new module and
300
+ `spec/pins`.
301
+ - The gemspec no longer lists a contact address (`spec.email` removed).
302
+
303
+ ## [0.2.0] - 2026-10-16
304
+
305
+ Statistics an app had written more than once, and its A/B-test allocator, in
306
+ one place. Pure Ruby, no new dependency; each function keeps the app's
307
+ arithmetic in the app's order, so on the inputs the app gives it (and the same
308
+ seed) it answers as the app did, bit for bit.
309
+
310
+ - `SkiftetStatistical::Gamma` — `regularized_lower(a, x)` and
311
+ `regularized_upper(a, x)`, the regularized incomplete gamma functions P and Q
312
+ (series below x = a + 1, Lentz continued fraction above).
313
+ - `SkiftetStatistical::BetaFunction` — `regularized(x, alpha, beta)`, the
314
+ regularized incomplete beta function, and `quantile(p, alpha, beta)`, its
315
+ inverse by bisection.
316
+ - `SkiftetStatistical::JointBeta` — `each_draw(posteriors, draws:, rng:)` (one
317
+ row per draw, arms in order, an Enumerator without a block) and
318
+ `summary(posteriors, draws:, rng:)` (`{ samples:, wins:, losses: }`, a tie
319
+ split equally).
320
+ - `Significance.normal_quantile(p)`, `.chi_squared_survival(statistic, dof)`
321
+ and `.sample_ratio_p_value(observed:, expected:, minimum_total: 200)`.
322
+ - `SkiftetStatistical::Bandit::Allocator` — the A/B-test allocator:
323
+ `call(arms:, tallies:, status:, winner_arm:, rng:, previous_weights: nil,
324
+ draws:, hold_satisfied: true, allocation: :adaptive, config: Config.new)`
325
+ returns `{ weights:, phase:, stats:, winner:, result_changed:, leader: }`
326
+ (Thompson sampling on joint Beta draws, a floor per arm, a step limit, an
327
+ automatic win and the won split); `won_weights(arms, winner, config:)`;
328
+ `Allocator::Config` (warmup_exposures, floor, max_step, win_p_best,
329
+ win_loss, won_share, won_rest, result_changed_below, credible_mass,
330
+ prior_alpha, prior_beta; the app's values by default); `Allocator::
331
+ FixedAllocation` (an `ArgumentError`), raised for a fixed split.
332
+ - `SkiftetStatistical::Bandit::WinHold` — the hold on an automatic win:
333
+ `new(leading_arm:, leading_since:, now:, last_pass_at:, hold_seconds:
334
+ 86_400, max_gap_seconds: 1_200)` with `satisfied?`, `interrupted?`,
335
+ `satisfied_for?(arm)` and `after_pass(leader)`.
336
+ - `SkiftetStatistical::Bandit::WeightedPosterior` — an arm's Beta posterior
337
+ from raw or inverse-propensity counts: `count(tally)`, `beta(count,
338
+ prior_alpha: 1.0, prior_beta: 1.0)`, `effective_exposures(count)` (the Kish
339
+ n, never above the raw count).
340
+ - The bandit simulations (`spec/simulations`, tagged `simulation: true`) run
341
+ only with `SIMULATIONS=1`; CI runs them nightly, by hand, and on a push or
342
+ pull request that changes the bandit code. Measured results: README,
343
+ "Bandit simulations: measured results".
344
+ - Unchanged: the 0.1 API, and how `Sampler` uses its RNG (a seed gives the same
345
+ draws as in 0.1).
346
+
3
347
  ## [0.1.0] - 2026-06-23
4
348
 
5
- Initial release — Skiftet's shared statistics toolkit.
349
+ Initial release — a shared statistics toolkit.
6
350
 
7
351
  - `SkiftetStatistical::Descriptive` — mean, variance, standard deviation,
8
352
  percentiles and median.
9
353
  - `SkiftetStatistical::Significance` — A/B significance testing: two-proportion
10
354
  z-test, Welch's t-test, exact normal CDF and two-tailed p-values (consolidates
11
- the duplicated significance math from mej.la and skram.la).
355
+ significance math that two apps had each written).
12
356
  - `SkiftetStatistical::Bandit` — arms + a pluggable policy, with `#select`,
13
357
  `#record`, `#best_arm`, `#stats`, and Hash (de)serialisation.
14
358
  - `SkiftetStatistical::Arm` — online reward statistics (pulls, mean, variance,