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/segments.py ADDED
@@ -0,0 +1,142 @@
1
+ """Which tool is best for which kind of task, and what routing by that would be worth.
2
+
3
+ Both questions invite self-deception, so both are answered conservatively:
4
+
5
+ breakdown per-stratum effects are noisy, and the largest of several noisy numbers is biased upwards. Raw
6
+ effects are shown beside empirical-Bayes shrunk ones (each pulled toward the overall effect in
7
+ proportion to its noise) and a Bonferroni-adjusted p-value. In simulation, shrinkage cut the
8
+ error of per-stratum effects by 33% to 82%, and raw tests raised about 0.45 false alarms per
9
+ 8-stratum report.
10
+ recommend "send each task type to its best tool" always looks good on the data that picked the tool. In a
11
+ world where the tools were identical it claimed gains of 1.4 to 3.0 percentage points on the same
12
+ data and 0.0 when the tool was chosen on one half and judged on the other. Here the choice and the
13
+ judging never share a decision.
14
+ """
15
+ import math
16
+ from collections import defaultdict
17
+
18
+ from .estimate import _counts, _mean_se, contributions, fold_of, floor_se
19
+
20
+ Z = 1.959964
21
+ MIN_CELL = 10 # decisions an arm needs within a stratum before it can win that stratum
22
+
23
+
24
+ def _p_two_sided(z):
25
+ return math.erfc(abs(z) / math.sqrt(2))
26
+
27
+
28
+ def _by_stratum(rows):
29
+ groups = defaultdict(list)
30
+ for i, r in enumerate(rows):
31
+ groups[r["s"]].append(i)
32
+ return groups
33
+
34
+
35
+ def shrink(effects, ses):
36
+ """Empirical-Bayes shrinkage (DerSimonian-Laird between-stratum variance). Returns (mean, tau2, shrunk, post_sd)."""
37
+ k = len(effects)
38
+ w = [1 / s ** 2 for s in ses]
39
+ mu = sum(wi * e for wi, e in zip(w, effects)) / sum(w)
40
+ if k < 2:
41
+ return mu, 0.0, list(effects), list(ses) # one stratum: nothing to borrow from
42
+ q = sum(wi * (e - mu) ** 2 for wi, e in zip(w, effects))
43
+ c = sum(w) - sum(wi * wi for wi in w) / sum(w)
44
+ tau2 = max(0.0, (q - (k - 1)) / c) if c > 0 else 0.0
45
+ shrunk, post = [], []
46
+ for e, s in zip(effects, ses):
47
+ b = tau2 / (tau2 + s * s) if tau2 + s * s > 0 else 0.0
48
+ shrunk.append(mu + b * (e - mu))
49
+ # Posterior spread: noise left in the shrunk part, plus uncertainty about the overall mean it shrinks toward.
50
+ post.append(math.sqrt(b * s * s + (1 - b) ** 2 / sum(w)))
51
+ return mu, tau2, shrunk, post
52
+
53
+
54
+ def breakdown(rows, arms, baseline, clip=None, folds=None):
55
+ """Per-stratum effect of every non-baseline arm against the baseline, raw and shrunk."""
56
+ c = contributions(rows, arms, clip, folds)
57
+ groups = _by_stratum(rows)
58
+ out = {}
59
+ for arm in arms:
60
+ if arm == baseline:
61
+ continue
62
+ cells = []
63
+ for stratum, idx in sorted(groups.items()):
64
+ diff = [c[arm]["dr"][i] - c[baseline]["dr"][i] for i in idx]
65
+ effect, se = _mean_se(diff) if len(idx) > 1 else (diff[0], float("nan"))
66
+ counts = _counts([rows[i] for i in idx], [arm, baseline])
67
+ se = max(se if se == se else 0.0, floor_se(counts[arm], counts[baseline]))
68
+ if se <= 0:
69
+ continue
70
+ cells.append({"stratum": stratum, "n": len(idx), "effect": effect, "se": se,
71
+ "arm_rate": sum(c[arm]["dr"][i] for i in idx) / len(idx),
72
+ "baseline_rate": sum(c[baseline]["dr"][i] for i in idx) / len(idx)})
73
+ if not cells:
74
+ continue
75
+ mu, tau2, shrunk, post = shrink([x["effect"] for x in cells], [x["se"] for x in cells])
76
+ m = len(cells)
77
+ for cell, sh, ps in zip(cells, shrunk, post):
78
+ cell.update(shrunk=sh, shrunk_lo=sh - Z * ps, shrunk_hi=sh + Z * ps,
79
+ raw_lo=cell["effect"] - Z * cell["se"], raw_hi=cell["effect"] + Z * cell["se"],
80
+ p=_p_two_sided(cell["effect"] / cell["se"]))
81
+ cell["p_adjusted"] = min(1.0, cell["p"] * m)
82
+ out[arm] = {"overall": mu, "tau": math.sqrt(tau2), "strata": cells,
83
+ "heterogeneous": bool(tau2 > 0 and any(x["p_adjusted"] < 0.05 for x in cells))}
84
+ return out
85
+
86
+
87
+ def _policy_from(rows, idx, c, arms, baseline):
88
+ """Arm per stratum chosen from the rows in idx only: lowest estimated bad rate among arms with enough data."""
89
+ groups = defaultdict(list)
90
+ for i in idx:
91
+ groups[rows[i]["s"]].append(i)
92
+ policy = {}
93
+ for stratum, members in groups.items():
94
+ best, best_rate = baseline, None
95
+ for arm in arms:
96
+ taken = sum(1 for i in members if rows[i]["arm"] == arm)
97
+ if taken < MIN_CELL:
98
+ continue
99
+ rate = sum(c[arm]["dr"][i] for i in members) / len(members)
100
+ if best_rate is None or rate < best_rate:
101
+ best, best_rate = arm, rate
102
+ policy[stratum] = best
103
+ return policy
104
+
105
+
106
+ def recommend(rows, arms, baseline, clip=None, folds=None, rounds=2):
107
+ """Cross-fitted value of routing each stratum to its best arm, against what actually happened.
108
+
109
+ Rows are split by a stable hash of their cluster (or id). The policy for one half is chosen from the
110
+ other half, so no decision both picks the winner and judges it. gain > 0 means fewer bad outcomes.
111
+ """
112
+ c = contributions(rows, arms, clip, folds)
113
+ side = [fold_of(r, rounds, i) for i, r in enumerate(rows)]
114
+ gains, single_gains = [], []
115
+ policies = {}
116
+ for k in range(rounds):
117
+ train = [i for i, s in enumerate(side) if s != k]
118
+ test = [i for i, s in enumerate(side) if s == k]
119
+ if not train or not test:
120
+ continue
121
+ policy = _policy_from(rows, train, c, arms, baseline)
122
+ best_single = min(arms, key=lambda a: (sum(c[a]["dr"][i] for i in train) / len(train)))
123
+ for i in test:
124
+ chosen = policy.get(rows[i]["s"], baseline)
125
+ gains.append(rows[i]["y"] - c[chosen]["dr"][i])
126
+ single_gains.append(rows[i]["y"] - c[best_single]["dr"][i])
127
+ for stratum, arm in policy.items():
128
+ policies.setdefault(stratum, []).append(arm)
129
+ if not gains:
130
+ raise ValueError("not enough decisions to split into a choosing half and a judging half")
131
+ gain, gain_se = _mean_se(gains)
132
+ single, single_se = _mean_se(single_gains)
133
+ agree = {s: arms_[0] for s, arms_ in policies.items() if len(set(arms_)) == 1}
134
+ return {
135
+ "n": len(gains),
136
+ "current_rate": sum(r["y"] for r in rows) / len(rows),
137
+ "gain": gain, "gain_lo": gain - Z * gain_se, "gain_hi": gain + Z * gain_se,
138
+ "best_single_gain": single, "best_single_lo": single - Z * single_se, "best_single_hi": single + Z * single_se,
139
+ "stable_policy": agree,
140
+ "unstable_strata": sorted(set(policies) - set(agree)),
141
+ "worth_it": gain - Z * gain_se > 0,
142
+ }
ledger/sentry.py ADDED
@@ -0,0 +1,91 @@
1
+ """Pull incidents from Sentry's REST API into the ledger.
2
+
3
+ Reads the token from the SENTRY_AUTH_TOKEN environment variable only; it is never accepted as an
4
+ argument, written to disk, or printed. Needs a read-only token (Issue & Event: Read).
5
+
6
+ Sentry's documented API has no suspect-commit endpoint, so this brings in incident timing and titles
7
+ but no commit links. Link them with `ledger import incidents` from an export that names suspect
8
+ commits, or with --incident-linking on `ledger label`.
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ import urllib.error
14
+ import urllib.parse
15
+ import urllib.request
16
+
17
+ from .incidents import import_incidents_records
18
+
19
+ BASE_URL = "https://sentry.io"
20
+ LINK_NEXT_RE = re.compile(r'<([^>]+)>;\s*rel="next";\s*results="true"')
21
+ MAX_PAGES = 50 # 100 issues each: a hard stop against runaway pagination
22
+
23
+
24
+ class SentryError(RuntimeError):
25
+ pass
26
+
27
+
28
+ def token_from_env():
29
+ token = os.environ.get("SENTRY_AUTH_TOKEN", "").strip()
30
+ if not token:
31
+ raise SentryError("SENTRY_AUTH_TOKEN is not set. Create a read-only token and store it with: "
32
+ 'setx SENTRY_AUTH_TOKEN "<token>" (then open a new terminal).')
33
+ return token
34
+
35
+
36
+ def _get(url, token, opener=urllib.request.urlopen):
37
+ req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
38
+ try:
39
+ with opener(req, timeout=30) as resp:
40
+ return json.load(resp), resp.headers.get("Link", "")
41
+ except urllib.error.HTTPError as e:
42
+ # Never echo request headers: the message carries the status and Sentry's own detail only.
43
+ detail = ""
44
+ try:
45
+ detail = json.load(e).get("detail", "")
46
+ except Exception:
47
+ pass
48
+ finally:
49
+ e.close()
50
+ hint = {401: "the token was rejected (wrong, expired or revoked)",
51
+ 403: "the token lacks permission (needs Issue & Event: Read)",
52
+ 404: "organization not found (check the slug and SENTRY_URL region)",
53
+ 429: "rate limited by Sentry; try again later"}.get(e.code, "")
54
+ raise SentryError(f"Sentry returned HTTP {e.code}: {hint or detail or e.reason}") from None
55
+ except urllib.error.URLError as e:
56
+ raise SentryError(f"could not reach Sentry: {e.reason}") from None
57
+
58
+
59
+ def fetch_issues(org, days=90, query="", token=None, base_url=None, opener=urllib.request.urlopen):
60
+ """All issues first seen in the last `days` days, following Sentry's cursor pagination."""
61
+ token = token or token_from_env()
62
+ base = (base_url or os.environ.get("SENTRY_URL") or BASE_URL).rstrip("/")
63
+ params = urllib.parse.urlencode({"statsPeriod": f"{int(days)}d", "query": query, "limit": 100})
64
+ url = f"{base}/api/0/organizations/{urllib.parse.quote(org, safe='')}/issues/?{params}"
65
+ issues = []
66
+ for _ in range(MAX_PAGES):
67
+ page, link = _get(url, token, opener)
68
+ issues.extend(page)
69
+ nxt = LINK_NEXT_RE.search(link or "")
70
+ if not nxt:
71
+ return issues
72
+ url = nxt.group(1)
73
+ raise SentryError(f"stopped after {MAX_PAGES} pages; narrow --days or --query")
74
+
75
+
76
+ def issues_to_incidents(issues, levels=("error", "fatal")):
77
+ """Incident records for import. Only error-level issues count; info and warnings are noise."""
78
+ out = []
79
+ for i in issues:
80
+ if levels and str(i.get("level", "")).lower() not in levels:
81
+ continue
82
+ out.append({"id": i.get("shortId") or i.get("id"), "started_at": i["firstSeen"],
83
+ "title": i.get("title"), "commits": "", "files": ""})
84
+ return out
85
+
86
+
87
+ def sync(con, org, days=90, query="", levels=("error", "fatal"), **kwargs):
88
+ issues = fetch_issues(org, days, query, **kwargs)
89
+ records = issues_to_incidents(issues, levels)
90
+ import_incidents_records(con, records)
91
+ return {"fetched": len(issues), "imported": len(records)}
ledger/squash.py ADDED
@@ -0,0 +1,92 @@
1
+ """Recover decisions whose commits were squash-merged.
2
+
3
+ A squash merge replaces a branch's commits with one new commit that has no `Ledger-Decision-Id`
4
+ trailer, so the decision looks as if it was never merged. That wrongly counts merged work as
5
+ abandoned and biases the comparison. The branch's commits usually still exist (as a branch, a
6
+ remote-tracking ref, or in the reflog), and a squash commit makes exactly the change the branch made.
7
+ So: fingerprint the branch's combined diff with `git patch-id --stable` and look for a commit on the
8
+ target branch with the same fingerprint.
9
+
10
+ Only a *unique* match is accepted, and never one that predates the branch's work: two commits can share
11
+ a fingerprint (the same one-line change made twice), and guessing would attach outcomes to the wrong
12
+ decision. Leftovers are reported, and `ledger attach` links them by hand.
13
+ """
14
+ from collections import defaultdict
15
+
16
+ from .gitio import GitError, git, log_commits
17
+
18
+ DIFF_ARGS = ("--no-renames", "--no-color", "--no-ext-diff")
19
+
20
+
21
+ def patch_id(repo, diff_text):
22
+ """git's stable fingerprint of a diff, or None for an empty one."""
23
+ out = git(repo, "patch-id", "--stable", input=diff_text).split()
24
+ return out[0] if out else None
25
+
26
+
27
+ def patch_ids(repo, rev_range):
28
+ """{commit sha: patch-id} for every non-merge commit in the range."""
29
+ log = git(repo, "log", "-p", "--no-merges", *DIFF_ARGS, rev_range)
30
+ mapping = {}
31
+ for line in git(repo, "patch-id", "--stable", input=log).splitlines():
32
+ parts = line.split()
33
+ if len(parts) == 2:
34
+ mapping[parts[1]] = parts[0]
35
+ return mapping
36
+
37
+
38
+ def recover_squashed(con, repo, rev, reachable, trailer_re):
39
+ """Attribute squash commits to decisions that have no reachable commit. Returns how many were recovered."""
40
+ reachable_shas = {c["sha"] for c in reachable}
41
+ owned = {r[0] for r in con.execute("SELECT decision_id, sha FROM commits") if r[1] in reachable_shas}
42
+ pending = {r[0] for r in con.execute("SELECT id FROM decisions")} - owned
43
+ if not pending:
44
+ return 0
45
+
46
+ branch_commits = defaultdict(list)
47
+ for c in log_commits(repo, all_refs=True):
48
+ if c["sha"] in reachable_shas:
49
+ continue
50
+ for decision_id in trailer_re.findall(c["body"]):
51
+ if decision_id in pending:
52
+ branch_commits[decision_id].append(c)
53
+ if not branch_commits:
54
+ return 0
55
+
56
+ head = git(repo, "rev-parse", "--verify", rev + "^{commit}").strip()
57
+ wanted = {} # decision id -> (fingerprint of its branch's combined diff, branch start time, merge base)
58
+ for decision_id, commits in branch_commits.items():
59
+ tip = max(commits, key=lambda c: c["ts"])
60
+ try:
61
+ base = git(repo, "merge-base", head, tip["sha"]).strip()
62
+ fingerprint = patch_id(repo, git(repo, "diff", *DIFF_ARGS, base, tip["sha"]))
63
+ except GitError:
64
+ continue # unrelated history, or an object git no longer has
65
+ if fingerprint:
66
+ wanted[decision_id] = (fingerprint, min(c["ts"] for c in commits), base)
67
+ if not wanted:
68
+ return 0
69
+
70
+ # One pass over everything merged since the oldest branch point; --no-merges keeps merge commits out.
71
+ oldest = min((int(git(repo, "log", "-1", "--format=%ct", base).strip()), base)
72
+ for _, _, base in wanted.values())[1]
73
+ try:
74
+ merged = patch_ids(repo, f"{oldest}..{head}")
75
+ except GitError:
76
+ return 0
77
+ when = {c["sha"]: c["ts"] for c in reachable}
78
+ by_fingerprint = defaultdict(list)
79
+ for sha, fingerprint in merged.items():
80
+ by_fingerprint[fingerprint].append(sha)
81
+
82
+ taken = {r[0] for r in con.execute("SELECT sha FROM commits")}
83
+ recovered = 0
84
+ for decision_id, (fingerprint, started, _) in sorted(wanted.items()):
85
+ matches = [s for s in by_fingerprint.get(fingerprint, []) if s not in taken and when.get(s, 0) >= started]
86
+ if len(matches) != 1:
87
+ continue # none, or ambiguous: never guess
88
+ sha = matches[0]
89
+ con.execute("INSERT OR IGNORE INTO commits VALUES (?,?,?,'squash')", (sha, decision_id, when[sha]))
90
+ taken.add(sha)
91
+ recovered += 1
92
+ return recovered
ledger/status.py ADDED
@@ -0,0 +1,56 @@
1
+ """A plain-language health check of a ledger: is anything stuck, missing, or about to mislead you?"""
2
+ import json
3
+ import time
4
+
5
+ DAY = 86400
6
+
7
+
8
+ def status(con, now=None, horizon_days=30):
9
+ now = int(now if now is not None else time.time())
10
+ one = lambda sql, *a: con.execute(sql, a).fetchone()[0]
11
+ out = {
12
+ "decisions": one("SELECT COUNT(*) FROM decisions"),
13
+ "attributed": one("SELECT COUNT(DISTINCT decision_id) FROM commits"),
14
+ "incidents": one("SELECT COUNT(*) FROM incidents"),
15
+ "deploys": one("SELECT COUNT(*) FROM deploys"),
16
+ "labeled": one("SELECT COUNT(*) FROM outcomes"),
17
+ "mature": one("SELECT COUNT(*) FROM outcomes WHERE mature = 1 AND kind != 'no_commit'"),
18
+ "last_label": one("SELECT MAX(as_of) FROM outcomes"),
19
+ "arms": {}, "notes": []}
20
+ for r in con.execute("SELECT chosen arm, COUNT(*) n, MIN(propensity) lo FROM decisions GROUP BY chosen"):
21
+ out["arms"][r["arm"]] = {"decisions": r["n"], "lowest_propensity": r["lo"]}
22
+ versions = [r[0] for r in con.execute("SELECT DISTINCT policy_version FROM decisions")]
23
+ out["policy_versions"] = versions
24
+
25
+ notes = out["notes"]
26
+ if not out["decisions"]:
27
+ notes.append("No decisions yet. Run `ledger decide` before starting each task.")
28
+ return out
29
+ gap = out["decisions"] - out["attributed"]
30
+ if gap > 0.2 * out["decisions"]:
31
+ notes.append(f"{gap} of {out['decisions']} decisions have no commit. Is the commit hook installed (`ledger init`), "
32
+ "and was `ledger done` run before unrelated work? Squash merges drop trailers: `ledger label` recovers most of "
33
+ "them by matching diffs; `ledger attach` links the rest by hand.")
34
+ if out["labeled"] == 0:
35
+ notes.append("Nothing labeled yet. Run `ledger label` once commits exist.")
36
+ elif out["last_label"] and now - out["last_label"] > 14 * DAY:
37
+ notes.append(f"Labels are {(now - out['last_label']) // DAY} days old. Re-run `ledger label`.")
38
+ if out["labeled"] and out["mature"] == 0:
39
+ oldest = one("SELECT MIN(ts) FROM decisions")
40
+ wait = max(0, (oldest + horizon_days * DAY - now) // DAY)
41
+ notes.append(f"No decision is {horizon_days} days old yet; the first estimate is about {wait} days away.")
42
+ if len(versions) > 1:
43
+ notes.append(f"{len(versions)} routing policy versions were used ({', '.join(sorted(versions))}). "
44
+ "Propensities are logged per decision so this is fine, but keep them stable if you can.")
45
+ for arm, a in out["arms"].items():
46
+ if a["lowest_propensity"] < 0.02:
47
+ notes.append(f"{arm} was sometimes chosen with under 2% probability. Those decisions carry huge weights; "
48
+ "raise --epsilon on `ledger decide`.")
49
+ if len(out["arms"]) == 1:
50
+ notes.append("Every decision went to one arm, so there is nothing to compare. Offer at least two arms to `decide`.")
51
+ if out["incidents"] and not out["deploys"]:
52
+ incidents_with_commits = sum(1 for r in con.execute("SELECT commits FROM incidents") if json.loads(r[0]))
53
+ if not incidents_with_commits:
54
+ notes.append("Incidents are imported but none names a suspect commit and no deploys are loaded, "
55
+ "so they cannot be linked to changes.")
56
+ return out