consequence-ledger 0.3.0__py3-none-any.whl
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.
- consequence_ledger-0.3.0.dist-info/METADATA +191 -0
- consequence_ledger-0.3.0.dist-info/RECORD +24 -0
- consequence_ledger-0.3.0.dist-info/WHEEL +5 -0
- consequence_ledger-0.3.0.dist-info/entry_points.txt +2 -0
- consequence_ledger-0.3.0.dist-info/licenses/LICENSE +21 -0
- consequence_ledger-0.3.0.dist-info/top_level.txt +1 -0
- ledger/__init__.py +2 -0
- ledger/__main__.py +3 -0
- ledger/audit.py +187 -0
- ledger/cli.py +637 -0
- ledger/db.py +70 -0
- ledger/demo.py +306 -0
- ledger/estimate.py +412 -0
- ledger/gitio.py +102 -0
- ledger/incidents.py +202 -0
- ledger/label.py +386 -0
- ledger/pagerduty.py +102 -0
- ledger/plan.py +62 -0
- ledger/policy.py +52 -0
- ledger/report.py +330 -0
- ledger/segments.py +142 -0
- ledger/sentry.py +91 -0
- ledger/squash.py +92 -0
- ledger/status.py +56 -0
ledger/estimate.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Off-policy estimates of each arm's bad-outcome rate.
|
|
2
|
+
|
|
3
|
+
For every arm a we estimate E[Y(a)]: the 30-day bad-outcome rate if *every*
|
|
4
|
+
decision had gone to a. Estimators:
|
|
5
|
+
|
|
6
|
+
naive -- plain mean of Y among decisions that got a (biased when routing
|
|
7
|
+
depends on the task, e.g. hard tasks go to the "strong" agent)
|
|
8
|
+
ipw -- inverse-propensity weighting with the logged probabilities
|
|
9
|
+
snipw -- self-normalised IPW (lower variance, small bias)
|
|
10
|
+
dm -- direct method: per-stratum outcome model, averaged over the population
|
|
11
|
+
dr -- doubly robust: dm plus an IPW correction of its residuals
|
|
12
|
+
|
|
13
|
+
Confidence intervals come from a percentile bootstrap over decisions.
|
|
14
|
+
"""
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import random
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
|
|
21
|
+
ESTIMATORS = ("naive", "ipw", "snipw", "dm", "dr")
|
|
22
|
+
# The outcome model is fitted on other folds than the rows it corrects (cross-fitting). In a simulation with
|
|
23
|
+
# 100 strata and 400 decisions the doubly-robust interval covered the truth 87.7% of the time fitted in-sample
|
|
24
|
+
# and 95.3% cross-fitted. Below MIN_CROSSFIT rows a fold is too small to fit anything, so it is switched off.
|
|
25
|
+
DEFAULT_FOLDS = 5
|
|
26
|
+
MIN_CROSSFIT = 50
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_rows(con, stratum_key="stratum", cluster_key=None, window="full"):
|
|
30
|
+
"""Attributed decisions whose outcome window has closed.
|
|
31
|
+
|
|
32
|
+
stratum_key: one context key, or several joined by commas ("difficulty,language"); the outcome
|
|
33
|
+
model then works per combination. cluster_key names a context field (e.g. "developer") whose
|
|
34
|
+
values share outcomes; intervals then resample whole clusters instead of single decisions.
|
|
35
|
+
window: "full" uses the main horizon; "early" the early-warning window; "both" keeps decisions
|
|
36
|
+
with both windows closed and adds the early label as "y_early".
|
|
37
|
+
"""
|
|
38
|
+
keys = [k.strip() for k in (stratum_key or "").split(",") if k.strip()]
|
|
39
|
+
where = {"full": "o.mature = 1", "early": "o.early_mature = 1",
|
|
40
|
+
"both": "o.mature = 1 AND o.early_mature = 1"}[window]
|
|
41
|
+
rows = []
|
|
42
|
+
query = f"""SELECT d.id, d.chosen, d.propensities, d.context, o.bad, o.early_bad
|
|
43
|
+
FROM decisions d JOIN outcomes o ON o.decision_id = d.id
|
|
44
|
+
WHERE {where} AND o.kind != 'no_commit'
|
|
45
|
+
ORDER BY d.ts, d.id"""
|
|
46
|
+
for r in con.execute(query):
|
|
47
|
+
context = json.loads(r["context"])
|
|
48
|
+
row = {
|
|
49
|
+
"id": r["id"],
|
|
50
|
+
"arm": r["chosen"],
|
|
51
|
+
"y": r["early_bad"] if window == "early" else r["bad"],
|
|
52
|
+
"p": json.loads(r["propensities"]),
|
|
53
|
+
"s": "|".join(str(context.get(k, "all")) for k in keys) if keys else "all",
|
|
54
|
+
"c": str(context.get(cluster_key, r["id"])) if cluster_key else r["id"],
|
|
55
|
+
}
|
|
56
|
+
if window == "both":
|
|
57
|
+
row["y_early"] = r["early_bad"]
|
|
58
|
+
rows.append(row)
|
|
59
|
+
return rows
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def early_readout(con, baseline, stratum_key="stratum", cluster_key=None, planned_n=2000):
|
|
63
|
+
"""First look from the early window, plus how well early differences have tracked final ones.
|
|
64
|
+
|
|
65
|
+
Early labels see only the bad outcomes that surface fast, so early differences are typically
|
|
66
|
+
smaller than final ones. The calibration compares both on decisions old enough to have both.
|
|
67
|
+
"""
|
|
68
|
+
rows = load_rows(con, stratum_key, cluster_key, "early")
|
|
69
|
+
if not rows:
|
|
70
|
+
return None
|
|
71
|
+
arms = sorted({r["arm"] for r in rows} | {a for r in rows for a in r["p"]})
|
|
72
|
+
if baseline not in arms:
|
|
73
|
+
baseline = arms[0]
|
|
74
|
+
early = analytic(rows, arms, baseline, t_star=planned_n)
|
|
75
|
+
result = {"n": len(rows), "baseline": baseline,
|
|
76
|
+
"arms": {a: {k: early[a]["dr"][k] for k in ("value", "diff", "diff_lo", "diff_hi", "cs_lo", "cs_hi")}
|
|
77
|
+
for a in arms}}
|
|
78
|
+
both = load_rows(con, stratum_key, cluster_key, "both")
|
|
79
|
+
total_bad = sum(r["y"] for r in both)
|
|
80
|
+
if both and total_bad:
|
|
81
|
+
early_rows = [dict(r, y=r["y_early"]) for r in both]
|
|
82
|
+
e = point_estimates(early_rows, arms)
|
|
83
|
+
f = point_estimates(both, arms)
|
|
84
|
+
pairs = [(e[a]["dr"] - e[baseline]["dr"], f[a]["dr"] - f[baseline]["dr"]) for a in arms if a != baseline]
|
|
85
|
+
result["calibration"] = {
|
|
86
|
+
"n": len(both),
|
|
87
|
+
# Share of the final bad outcomes that had already surfaced in the early window.
|
|
88
|
+
"surfaced_early": sum(r["y_early"] for r in both) / total_bad,
|
|
89
|
+
"pairs": {a: {"early_diff": pe, "final_diff": pf} for a, (pe, pf) in zip([a for a in arms if a != baseline], pairs)},
|
|
90
|
+
"same_direction": sum((pe > 0) == (pf > 0) for pe, pf in pairs if pe and pf),
|
|
91
|
+
"compared": sum(1 for pe, pf in pairs if pe and pf),
|
|
92
|
+
}
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def merge_balance(con):
|
|
97
|
+
"""Per-arm share of decisions that never merged, and a chi-square test that it is the same for all arms.
|
|
98
|
+
|
|
99
|
+
Unmerged decisions are excluded from estimation. If one arm's work is abandoned more often,
|
|
100
|
+
the merged sets are no longer comparable and the estimates are biased.
|
|
101
|
+
"""
|
|
102
|
+
counts = {}
|
|
103
|
+
for r in con.execute("""SELECT d.chosen arm, SUM(o.kind = 'no_commit') unmerged, COUNT(*) n
|
|
104
|
+
FROM decisions d JOIN outcomes o ON o.decision_id = d.id GROUP BY d.chosen"""):
|
|
105
|
+
counts[r["arm"]] = (r["unmerged"], r["n"])
|
|
106
|
+
total_u = sum(u for u, _ in counts.values())
|
|
107
|
+
total_n = sum(n for _, n in counts.values())
|
|
108
|
+
chi2 = 0.0
|
|
109
|
+
if 0 < total_u < total_n:
|
|
110
|
+
rate = total_u / total_n
|
|
111
|
+
for u, n in counts.values():
|
|
112
|
+
for observed, expected in ((u, n * rate), (n - u, n * (1 - rate))):
|
|
113
|
+
if expected > 0:
|
|
114
|
+
chi2 += (observed - expected) ** 2 / expected
|
|
115
|
+
df = len(counts) - 1
|
|
116
|
+
return {"arms": {a: {"unmerged": u, "n": n, "rate": u / n if n else float("nan")} for a, (u, n) in counts.items()},
|
|
117
|
+
"chi2": chi2, "df": df, "p": chi2_sf(chi2, df) if df > 0 else None}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def chi2_sf(x, df):
|
|
121
|
+
"""Upper tail of the chi-square distribution (Wilson-Hilferty approximation; fine for a warning)."""
|
|
122
|
+
if x <= 0:
|
|
123
|
+
return 1.0
|
|
124
|
+
z = ((x / df) ** (1 / 3) - (1 - 2 / (9 * df))) / math.sqrt(2 / (9 * df))
|
|
125
|
+
return 0.5 * math.erfc(z / math.sqrt(2))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def outcome_model(rows):
|
|
129
|
+
"""Mean Y per (stratum, arm), falling back to the arm mean for empty cells."""
|
|
130
|
+
cell = defaultdict(lambda: [0, 0])
|
|
131
|
+
arm_tot = defaultdict(lambda: [0, 0])
|
|
132
|
+
for r in rows:
|
|
133
|
+
cell[(r["s"], r["arm"])][0] += r["y"]
|
|
134
|
+
cell[(r["s"], r["arm"])][1] += 1
|
|
135
|
+
arm_tot[r["arm"]][0] += r["y"]
|
|
136
|
+
arm_tot[r["arm"]][1] += 1
|
|
137
|
+
|
|
138
|
+
def model(s, a):
|
|
139
|
+
y, k = cell.get((s, a), (0, 0))
|
|
140
|
+
if k:
|
|
141
|
+
return y / k
|
|
142
|
+
y, k = arm_tot.get(a, (0, 0))
|
|
143
|
+
return y / k if k else float("nan")
|
|
144
|
+
|
|
145
|
+
return model
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def fold_of(row, folds, position=0):
|
|
149
|
+
"""Stable fold for a row. Rows of one cluster share a fold, so correlated outcomes never leak across folds."""
|
|
150
|
+
key = str(row.get("c", row.get("id", position)))
|
|
151
|
+
return int(hashlib.sha1(key.encode("utf-8")).hexdigest()[:8], 16) % folds
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tally(rows):
|
|
155
|
+
cell, arm_tot = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
|
|
156
|
+
for r in rows:
|
|
157
|
+
for table, key in ((cell, (r["s"], r["arm"])), (arm_tot, r["arm"])):
|
|
158
|
+
table[key][0] += r["y"]
|
|
159
|
+
table[key][1] += 1
|
|
160
|
+
return cell, arm_tot
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _model_from(cell, arm_tot):
|
|
164
|
+
def model(s, a):
|
|
165
|
+
y, k = cell.get((s, a), (0, 0))
|
|
166
|
+
if k:
|
|
167
|
+
return y / k
|
|
168
|
+
y, k = arm_tot.get(a, (0, 0))
|
|
169
|
+
return y / k if k else float("nan")
|
|
170
|
+
|
|
171
|
+
return model
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def row_models(rows, folds=None):
|
|
175
|
+
"""The outcome model to use for each row: fitted on the other folds when folds >= 2, else on all rows.
|
|
176
|
+
|
|
177
|
+
Counts are tallied once per fold and subtracted from the totals, so cross-fitting costs one pass.
|
|
178
|
+
"""
|
|
179
|
+
full = outcome_model(rows)
|
|
180
|
+
if not folds or folds < 2 or len(rows) < folds:
|
|
181
|
+
return [full] * len(rows)
|
|
182
|
+
assign = []
|
|
183
|
+
for i, r in enumerate(rows):
|
|
184
|
+
if r.get("_folds") != folds: # cached on the row: bootstrap resamples reuse the same row objects
|
|
185
|
+
r["_fold"], r["_folds"] = fold_of(r, folds, i), folds
|
|
186
|
+
assign.append(r["_fold"])
|
|
187
|
+
per_fold = [(defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])) for _ in range(folds)]
|
|
188
|
+
total_cell, total_arm = defaultdict(lambda: [0, 0]), defaultdict(lambda: [0, 0])
|
|
189
|
+
for r, f in zip(rows, assign):
|
|
190
|
+
key = (r["s"], r["arm"])
|
|
191
|
+
for table, k in ((per_fold[f][0], key), (per_fold[f][1], r["arm"]), (total_cell, key), (total_arm, r["arm"])):
|
|
192
|
+
table[k][0] += r["y"]
|
|
193
|
+
table[k][1] += 1
|
|
194
|
+
models = []
|
|
195
|
+
for k in range(folds):
|
|
196
|
+
cell_k, arm_k = per_fold[k]
|
|
197
|
+
cell = {key: (v[0] - cell_k[key][0], v[1] - cell_k[key][1]) for key, v in total_cell.items()} if cell_k else total_cell
|
|
198
|
+
arm = {key: (v[0] - arm_k[key][0], v[1] - arm_k[key][1]) for key, v in total_arm.items()} if arm_k else total_arm
|
|
199
|
+
fitted = _model_from(cell, arm)
|
|
200
|
+
|
|
201
|
+
def model(s, a, fitted=fitted):
|
|
202
|
+
value = fitted(s, a)
|
|
203
|
+
return full(s, a) if value != value else value # an arm unseen in the training folds
|
|
204
|
+
|
|
205
|
+
models.append(model)
|
|
206
|
+
return [models[f] for f in assign]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def contributions(rows, arms, clip=None, folds=None):
|
|
210
|
+
"""Per-decision terms whose means are the IPW and DR estimates (their influence functions)."""
|
|
211
|
+
models = row_models(rows, folds)
|
|
212
|
+
out = {}
|
|
213
|
+
for a in arms:
|
|
214
|
+
ipw, dr, dm, w_all = [], [], [], []
|
|
215
|
+
for r, model in zip(rows, models):
|
|
216
|
+
m = model(r["s"], a)
|
|
217
|
+
w = 0.0
|
|
218
|
+
if r["arm"] == a:
|
|
219
|
+
w = 1.0 / r["p"][a]
|
|
220
|
+
if clip:
|
|
221
|
+
w = min(w, clip)
|
|
222
|
+
ipw.append(w * r["y"])
|
|
223
|
+
dr.append(m + w * (r["y"] - m))
|
|
224
|
+
dm.append(m)
|
|
225
|
+
w_all.append(w)
|
|
226
|
+
out[a] = {"ipw": ipw, "dr": dr, "dm": dm, "w": w_all}
|
|
227
|
+
return out
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def point_estimates(rows, arms, clip=None, folds=None):
|
|
231
|
+
n = len(rows)
|
|
232
|
+
nan = float("nan")
|
|
233
|
+
out = {}
|
|
234
|
+
for a, c in contributions(rows, arms, clip, folds).items():
|
|
235
|
+
taken = [r["y"] for r in rows if r["arm"] == a]
|
|
236
|
+
w_sum = sum(c["w"])
|
|
237
|
+
out[a] = {
|
|
238
|
+
"naive": sum(taken) / len(taken) if taken else nan,
|
|
239
|
+
"ipw": sum(c["ipw"]) / n if n else nan,
|
|
240
|
+
"snipw": sum(c["ipw"]) / w_sum if w_sum else nan,
|
|
241
|
+
"dm": sum(c["dm"]) / n if n else nan,
|
|
242
|
+
"dr": sum(c["dr"]) / n if n else nan,
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _mean_se(values, clusters=None):
|
|
248
|
+
"""Mean and standard error; with clusters, the cluster-robust (sandwich) standard error."""
|
|
249
|
+
n = len(values)
|
|
250
|
+
mean = sum(values) / n
|
|
251
|
+
if clusters is None:
|
|
252
|
+
var = sum((v - mean) ** 2 for v in values) / (n - 1) if n > 1 else float("nan")
|
|
253
|
+
return mean, math.sqrt(var / n)
|
|
254
|
+
sums = defaultdict(float)
|
|
255
|
+
for v, g in zip(values, clusters):
|
|
256
|
+
sums[g] += v - mean
|
|
257
|
+
k = len(sums)
|
|
258
|
+
if k < 2:
|
|
259
|
+
return mean, float("nan")
|
|
260
|
+
return mean, math.sqrt(k / (k - 1) * sum(t * t for t in sums.values())) / n
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cs_radius(sigma, t, alpha=0.05, t_star=2000):
|
|
264
|
+
"""Half-width of the Gaussian-mixture asymptotic confidence sequence.
|
|
265
|
+
|
|
266
|
+
Waudby-Smith, Arbour, Sinha, Kennedy & Ramdas, "Time-uniform central limit theory and
|
|
267
|
+
asymptotic confidence sequences", Ann. Statist. 52(6), 2024, Eq. 13. Unlike a fixed-n CI it
|
|
268
|
+
stays valid when the report is re-run every week. rho is fixed in advance so the sequence is
|
|
269
|
+
tightest near t_star decisions; it must not be tuned on the data.
|
|
270
|
+
"""
|
|
271
|
+
if t < 2 or math.isnan(sigma):
|
|
272
|
+
return float("nan")
|
|
273
|
+
rho2 = (-2 * math.log(alpha) + math.log(-2 * math.log(alpha) + 1)) / t_star
|
|
274
|
+
return sigma * math.sqrt(2 * (t * rho2 + 1) / (t * t * rho2) * math.log(math.sqrt(t * rho2 + 1) / alpha))
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def floor_se(counts_a, counts_b=None):
|
|
278
|
+
"""Smallest standard error believable for a rate (or a difference of two) given how few bad outcomes it saw.
|
|
279
|
+
|
|
280
|
+
Adds one pseudo-bad and one pseudo-clean outcome per arm (Agresti and Caffo 2000). In simulation, intervals
|
|
281
|
+
for a 3% bad rate from 300 decisions covered 92.5% of the time without this floor and 96.5% with it, and
|
|
282
|
+
it changes nothing visible once there are hundreds of bad outcomes. counts are (bad, n) pairs.
|
|
283
|
+
"""
|
|
284
|
+
var = 0.0
|
|
285
|
+
for counts in (counts_a, counts_b):
|
|
286
|
+
if counts and counts[1] > 0:
|
|
287
|
+
p = (counts[0] + 1) / (counts[1] + 2)
|
|
288
|
+
var += p * (1 - p) / counts[1]
|
|
289
|
+
return math.sqrt(var)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _counts(rows, arms):
|
|
293
|
+
out = {a: [0, 0] for a in arms}
|
|
294
|
+
for r in rows:
|
|
295
|
+
if r["arm"] in out:
|
|
296
|
+
out[r["arm"]][0] += r["y"]
|
|
297
|
+
out[r["arm"]][1] += 1
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def analytic(rows, arms, baseline, clip=None, z=1.959964, t_star=2000, folds=None):
|
|
302
|
+
"""Normal-approximation CIs from influence functions: fast, no resampling.
|
|
303
|
+
|
|
304
|
+
Treats the outcome model as fixed, which is accurate when strata are few and well populated.
|
|
305
|
+
"""
|
|
306
|
+
c = contributions(rows, arms, clip, folds)
|
|
307
|
+
clusters = [r.get("c") for r in rows] if is_clustered(rows) else None
|
|
308
|
+
counts = _counts(rows, arms)
|
|
309
|
+
result = {}
|
|
310
|
+
for a in arms:
|
|
311
|
+
result[a] = {}
|
|
312
|
+
for e in ("ipw", "dr"):
|
|
313
|
+
m, se = _mean_se(c[a][e], clusters)
|
|
314
|
+
dm, dse = _mean_se([x - y for x, y in zip(c[a][e], c[baseline][e])], clusters)
|
|
315
|
+
se = max(se, floor_se(counts[a]))
|
|
316
|
+
dse = max(dse, floor_se(counts[a], counts[baseline]) if a != baseline else 0.0)
|
|
317
|
+
# se is sigma/sqrt(n) (cluster-robust if clustered), so sigma = se * sqrt(n).
|
|
318
|
+
n = len(rows)
|
|
319
|
+
r = cs_radius(dse * math.sqrt(n), n, 1 - math.erf(z / math.sqrt(2)), t_star)
|
|
320
|
+
result[a][e] = {"value": m, "se": se, "lo": m - z * se, "hi": m + z * se,
|
|
321
|
+
"diff": dm, "diff_se": dse, "diff_lo": dm - z * dse, "diff_hi": dm + z * dse,
|
|
322
|
+
"cs_lo": dm - r, "cs_hi": dm + r}
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_clustered(rows):
|
|
327
|
+
return len({r.get("c", i) for i, r in enumerate(rows)}) < len(rows)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _percentile(values, q):
|
|
331
|
+
values = sorted(v for v in values if not math.isnan(v))
|
|
332
|
+
if not values:
|
|
333
|
+
return float("nan")
|
|
334
|
+
i = (len(values) - 1) * q
|
|
335
|
+
lo, hi = math.floor(i), math.ceil(i)
|
|
336
|
+
return values[lo] + (values[hi] - values[lo]) * (i - lo)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def diagnostics(rows, arms):
|
|
340
|
+
diag = {}
|
|
341
|
+
for a in arms:
|
|
342
|
+
w = [1.0 / r["p"][a] for r in rows if r["arm"] == a]
|
|
343
|
+
diag[a] = {
|
|
344
|
+
"n": len(w),
|
|
345
|
+
"bad": sum(r["y"] for r in rows if r["arm"] == a),
|
|
346
|
+
"min_propensity": min((r["p"][a] for r in rows), default=float("nan")),
|
|
347
|
+
"max_weight": max(w, default=float("nan")),
|
|
348
|
+
# Kish effective sample size of the IPW weights.
|
|
349
|
+
"ess": (sum(w) ** 2 / sum(x * x for x in w)) if w else 0.0,
|
|
350
|
+
}
|
|
351
|
+
return diag
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _widen(value, bound, pick):
|
|
355
|
+
return bound if value != value else pick(value, bound)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def estimate(rows, arms=None, baseline=None, boot=500, clip=None, seed=0, alpha=0.05, planned_n=2000,
|
|
359
|
+
folds=DEFAULT_FOLDS):
|
|
360
|
+
if not rows:
|
|
361
|
+
raise ValueError("no mature, attributed decisions to estimate from")
|
|
362
|
+
arms = arms or sorted({r["arm"] for r in rows} | {a for r in rows for a in r["p"]})
|
|
363
|
+
baseline = baseline or arms[0]
|
|
364
|
+
if baseline not in arms:
|
|
365
|
+
raise ValueError(f"baseline {baseline!r} is not one of {arms}")
|
|
366
|
+
folds = folds if folds and len(rows) >= MIN_CROSSFIT else 0
|
|
367
|
+
point = point_estimates(rows, arms, clip, folds)
|
|
368
|
+
|
|
369
|
+
rng = random.Random(seed)
|
|
370
|
+
samples = defaultdict(list) # (arm, est) and ("diff", arm, est)
|
|
371
|
+
n = len(rows)
|
|
372
|
+
groups = defaultdict(list)
|
|
373
|
+
for i, r in enumerate(rows):
|
|
374
|
+
groups[r.get("c", i)].append(r)
|
|
375
|
+
groups = list(groups.values())
|
|
376
|
+
for _ in range(boot):
|
|
377
|
+
# Resample whole clusters so correlated outcomes (same developer, same service) widen the CI.
|
|
378
|
+
resample = [r for _ in range(len(groups)) for r in groups[rng.randrange(len(groups))]]
|
|
379
|
+
b = point_estimates(resample, arms, clip, folds)
|
|
380
|
+
for a in arms:
|
|
381
|
+
for e in ESTIMATORS:
|
|
382
|
+
samples[(a, e)].append(b[a][e])
|
|
383
|
+
samples[("diff", a, e)].append(b[a][e] - b[baseline][e])
|
|
384
|
+
|
|
385
|
+
lo_q, hi_q = alpha / 2, 1 - alpha / 2
|
|
386
|
+
result = {"baseline": baseline, "arms": {}, "n": n, "clusters": len(groups), "boot": boot, "clip": clip,
|
|
387
|
+
"diagnostics": diagnostics(rows, arms),
|
|
388
|
+
"analytic": analytic(rows, arms, baseline, clip, t_star=planned_n, folds=folds),
|
|
389
|
+
"planned_n": planned_n, "folds": folds}
|
|
390
|
+
counts = _counts(rows, arms)
|
|
391
|
+
z = 1.959964
|
|
392
|
+
for a in arms:
|
|
393
|
+
entry = {"estimates": {}, "vs_baseline": {}}
|
|
394
|
+
half = z * floor_se(counts[a])
|
|
395
|
+
half_diff = z * floor_se(counts[a], counts[baseline]) if a != baseline else 0.0
|
|
396
|
+
for e in ESTIMATORS:
|
|
397
|
+
entry["estimates"][e] = {
|
|
398
|
+
"value": point[a][e],
|
|
399
|
+
"lo": _widen(_percentile(samples[(a, e)], lo_q), point[a][e] - half, min),
|
|
400
|
+
"hi": _widen(_percentile(samples[(a, e)], hi_q), point[a][e] + half, max),
|
|
401
|
+
}
|
|
402
|
+
diffs = samples[("diff", a, e)]
|
|
403
|
+
entry["vs_baseline"][e] = {
|
|
404
|
+
"value": point[a][e] - point[baseline][e],
|
|
405
|
+
"lo": _widen(_percentile(diffs, lo_q), point[a][e] - point[baseline][e] - half_diff, min),
|
|
406
|
+
"hi": _widen(_percentile(diffs, hi_q), point[a][e] - point[baseline][e] + half_diff, max),
|
|
407
|
+
# Two-sided bootstrap p-value for "no difference".
|
|
408
|
+
"p": (min(1.0, 2 * min(sum(d <= 0 for d in diffs), sum(d >= 0 for d in diffs)) / len(diffs))
|
|
409
|
+
if a != baseline and diffs else None),
|
|
410
|
+
}
|
|
411
|
+
result["arms"][a] = entry
|
|
412
|
+
return result
|
ledger/gitio.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Thin wrappers around the git CLI."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitError(RuntimeError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def git(repo, *args, input=None):
|
|
12
|
+
"""Run git in repo and return its stdout. `input` (str) is fed to stdin."""
|
|
13
|
+
data = input.encode("utf-8", "replace") if isinstance(input, str) else input
|
|
14
|
+
proc = subprocess.run(["git", "-C", str(repo), *args], capture_output=True, input=data)
|
|
15
|
+
if proc.returncode != 0:
|
|
16
|
+
raise GitError(f"git {' '.join(args)}: {proc.stderr.decode('utf-8', 'replace').strip()}")
|
|
17
|
+
return proc.stdout.decode("utf-8", "replace")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def repo_root(path="."):
|
|
21
|
+
"""Root of the main working tree for any folder inside a repository or one of its linked worktrees.
|
|
22
|
+
|
|
23
|
+
The ledger lives there, so every worktree and subfolder shares one. A folder that is not inside
|
|
24
|
+
a git repository is returned unchanged and each command reports its own error.
|
|
25
|
+
"""
|
|
26
|
+
path = Path(path)
|
|
27
|
+
try:
|
|
28
|
+
common = git(path, "rev-parse", "--git-common-dir").strip()
|
|
29
|
+
except (GitError, OSError):
|
|
30
|
+
return path
|
|
31
|
+
common = Path(common) if Path(common).is_absolute() else path / common
|
|
32
|
+
common = common.resolve()
|
|
33
|
+
if common.name == ".git":
|
|
34
|
+
return common.parent
|
|
35
|
+
return Path(git(path, "rev-parse", "--show-toplevel").strip()) # bare or custom gitdir layouts
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def current_branch(repo):
|
|
39
|
+
"""The checked-out branch name, or None when HEAD is detached."""
|
|
40
|
+
try:
|
|
41
|
+
return git(repo, "symbolic-ref", "--short", "-q", "HEAD").strip() or None
|
|
42
|
+
except GitError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def branch_key(branch):
|
|
47
|
+
"""Filename for a branch's active decision: git's own blob hash of the name, so any branch name
|
|
48
|
+
(slashes, unicode, 'a/b' vs 'a_b') maps to a distinct, filesystem-safe key. The commit hook
|
|
49
|
+
computes the same value with `git hash-object --stdin`."""
|
|
50
|
+
# A detached HEAD gets a name no real branch can have: git forbids spaces in ref names.
|
|
51
|
+
data = (branch if branch is not None else "(detached HEAD)").encode("utf-8")
|
|
52
|
+
return hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def safe_rev(rev):
|
|
56
|
+
"""A revision from user input, refused if git could mistake it for an option (e.g. --output=file)."""
|
|
57
|
+
rev = str(rev)
|
|
58
|
+
if not rev or rev.startswith("-") or "\0" in rev or "\n" in rev:
|
|
59
|
+
raise ValueError(f"{rev!r} is not a valid revision (it must not start with '-')")
|
|
60
|
+
return rev
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def log_commits(repo, rev="HEAD", all_refs=False):
|
|
64
|
+
"""All commits reachable from rev, oldest first.
|
|
65
|
+
|
|
66
|
+
With all_refs, every commit git still has: all branches, remote-tracking refs and reflog entries.
|
|
67
|
+
That includes work on branches that were squash-merged and deleted.
|
|
68
|
+
"""
|
|
69
|
+
scope = ["--all", "--reflog"] if all_refs else [safe_rev(rev)]
|
|
70
|
+
out = git(repo, "log", "--reverse", "--format=%H%x1f%P%x1f%ct%x1f%an <%ae>%x1f%B%x1e", *scope)
|
|
71
|
+
commits = []
|
|
72
|
+
for record in out.split("\x1e"):
|
|
73
|
+
record = record.strip("\n")
|
|
74
|
+
if not record:
|
|
75
|
+
continue
|
|
76
|
+
sha, parents, ts, author, body = record.split("\x1f", 4)
|
|
77
|
+
commits.append({
|
|
78
|
+
"sha": sha,
|
|
79
|
+
"parents": parents.split(),
|
|
80
|
+
"ts": int(ts),
|
|
81
|
+
"author": author,
|
|
82
|
+
"body": body,
|
|
83
|
+
"subject": body.split("\n", 1)[0],
|
|
84
|
+
})
|
|
85
|
+
return commits
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def lines_changed(repo, rev="HEAD"):
|
|
89
|
+
"""{sha: lines added + deleted} for non-merge commits (binary files count as 0)."""
|
|
90
|
+
out = git(repo, "log", "--no-merges", "--format=%x1e%H", "--numstat", safe_rev(rev))
|
|
91
|
+
sizes = {}
|
|
92
|
+
for record in out.split("\x1e"):
|
|
93
|
+
parts = record.strip().split("\n")
|
|
94
|
+
if not parts or not parts[0]:
|
|
95
|
+
continue
|
|
96
|
+
total = 0
|
|
97
|
+
for line in parts[1:]:
|
|
98
|
+
cols = line.split("\t")
|
|
99
|
+
if len(cols) >= 3:
|
|
100
|
+
total += sum(int(c) for c in cols[:2] if c.isdigit())
|
|
101
|
+
sizes[parts[0]] = total
|
|
102
|
+
return sizes
|