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.
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