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/pagerduty.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Pull incidents from PagerDuty's REST API into the ledger.
|
|
2
|
+
|
|
3
|
+
Reads the key from the PAGERDUTY_API_KEY environment variable only; it is never accepted as an
|
|
4
|
+
argument, written to disk, or printed. A read-only REST API key is enough.
|
|
5
|
+
|
|
6
|
+
PagerDuty incidents carry no commit links, so these bring in timing and titles. Link them to code with
|
|
7
|
+
an export that names suspect commits, or with --incident-linking on `ledger label`.
|
|
8
|
+
"""
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
from .incidents import import_incidents_records
|
|
17
|
+
|
|
18
|
+
BASE_URL = "https://api.pagerduty.com" # EU accounts: set PAGERDUTY_URL=https://api.eu.pagerduty.com
|
|
19
|
+
WINDOW_DAYS = 150 # the API rejects since/until ranges over 6 months
|
|
20
|
+
PAGE_LIMIT = 100
|
|
21
|
+
MAX_PAGES_PER_WINDOW = 100 # classic pagination stops at offset + limit = 10,000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PagerDutyError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def key_from_env():
|
|
29
|
+
key = os.environ.get("PAGERDUTY_API_KEY", "").strip()
|
|
30
|
+
if not key:
|
|
31
|
+
raise PagerDutyError("PAGERDUTY_API_KEY is not set. Create a read-only REST API key and store it with: "
|
|
32
|
+
'setx PAGERDUTY_API_KEY "<key>" (then open a new terminal).')
|
|
33
|
+
return key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get(url, key, opener=urllib.request.urlopen):
|
|
37
|
+
req = urllib.request.Request(url, headers={
|
|
38
|
+
"Authorization": f"Token token={key}",
|
|
39
|
+
"Accept": "application/vnd.pagerduty+json;version=2",
|
|
40
|
+
})
|
|
41
|
+
try:
|
|
42
|
+
with opener(req, timeout=30) as resp:
|
|
43
|
+
return json.load(resp)
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
hint = {401: "the key was rejected (wrong, disabled or deleted)",
|
|
46
|
+
403: "the key lacks permission for incidents",
|
|
47
|
+
429: "rate limited by PagerDuty; try again later"}.get(e.code, e.reason)
|
|
48
|
+
e.close()
|
|
49
|
+
raise PagerDutyError(f"PagerDuty returned HTTP {e.code}: {hint}") from None
|
|
50
|
+
except urllib.error.URLError as e:
|
|
51
|
+
raise PagerDutyError(f"could not reach PagerDuty: {e.reason}") from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def windows(days, now=None, size=WINDOW_DAYS):
|
|
55
|
+
"""(since, until) ISO pairs covering the last `days` days in chunks the API accepts."""
|
|
56
|
+
now = now or dt.datetime.now(dt.timezone.utc)
|
|
57
|
+
start = now - dt.timedelta(days=days)
|
|
58
|
+
out, cursor = [], start
|
|
59
|
+
while cursor < now:
|
|
60
|
+
end = min(cursor + dt.timedelta(days=size), now)
|
|
61
|
+
out.append((cursor.strftime("%Y-%m-%dT%H:%M:%SZ"), end.strftime("%Y-%m-%dT%H:%M:%SZ")))
|
|
62
|
+
cursor = end
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def fetch_incidents(days=90, urgencies=("high",), key=None, base_url=None, opener=urllib.request.urlopen, now=None):
|
|
67
|
+
"""Incidents created in the last `days` days. Duplicates across window edges are removed."""
|
|
68
|
+
key = key or key_from_env()
|
|
69
|
+
base = (base_url or os.environ.get("PAGERDUTY_URL") or BASE_URL).rstrip("/")
|
|
70
|
+
found = {}
|
|
71
|
+
for since, until in windows(days, now):
|
|
72
|
+
offset = 0
|
|
73
|
+
for _ in range(MAX_PAGES_PER_WINDOW):
|
|
74
|
+
params = [("since", since), ("until", until), ("limit", PAGE_LIMIT), ("offset", offset),
|
|
75
|
+
("sort_by", "created_at")]
|
|
76
|
+
params += [("urgencies[]", u) for u in urgencies]
|
|
77
|
+
page = _get(f"{base}/incidents?{urllib.parse.urlencode(params)}", key, opener)
|
|
78
|
+
for inc in page.get("incidents", []):
|
|
79
|
+
found[inc["id"]] = inc
|
|
80
|
+
if not page.get("more"):
|
|
81
|
+
break
|
|
82
|
+
offset += PAGE_LIMIT
|
|
83
|
+
else:
|
|
84
|
+
raise PagerDutyError(f"more than {MAX_PAGES_PER_WINDOW * PAGE_LIMIT} incidents in one window; "
|
|
85
|
+
"narrow --days or --urgencies")
|
|
86
|
+
return list(found.values())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def to_records(incidents):
|
|
90
|
+
out = []
|
|
91
|
+
for i in incidents:
|
|
92
|
+
service = (i.get("service") or {}).get("summary", "")
|
|
93
|
+
title = i.get("title") or ""
|
|
94
|
+
out.append({"id": f"PD-{i.get('incident_number', i['id'])}", "started_at": i["created_at"],
|
|
95
|
+
"title": f"{service}: {title}" if service else title, "commits": "", "files": ""})
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sync(con, days=90, urgencies=("high",), **kwargs):
|
|
100
|
+
incidents = fetch_incidents(days, urgencies, **kwargs)
|
|
101
|
+
import_incidents_records(con, to_records(incidents))
|
|
102
|
+
return {"fetched": len(incidents), "imported": len(incidents)}
|
ledger/plan.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Sample-size planning by simulation: how many finished decisions to detect a given difference.
|
|
2
|
+
|
|
3
|
+
Simulates routing with the same policy `ledger decide` uses (softmax over scores plus an
|
|
4
|
+
epsilon floor), draws outcomes from the base rates you give, and runs the real estimator on
|
|
5
|
+
each simulated dataset. Reports power for both the ordinary 95% interval and the
|
|
6
|
+
anytime-valid interval that is safe to check repeatedly.
|
|
7
|
+
"""
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
from .estimate import analytic
|
|
11
|
+
from .policy import propensities
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def simulate(n, rng, rates, epsilon, prefer):
|
|
15
|
+
"""n decisions over arms named by `rates`; the last arm is preferred by `prefer` score points."""
|
|
16
|
+
arms = list(rates)
|
|
17
|
+
scores = [0.0] * (len(arms) - 1) + [prefer]
|
|
18
|
+
probs = dict(zip(arms, propensities(scores, epsilon, 1.0)))
|
|
19
|
+
weights = [probs[a] for a in arms]
|
|
20
|
+
rows = []
|
|
21
|
+
for _ in range(n):
|
|
22
|
+
arm = rng.choices(arms, weights=weights)[0]
|
|
23
|
+
rows.append({"arm": arm, "y": int(rng.random() < rates[arm]), "p": probs, "s": "all"})
|
|
24
|
+
return rows
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def power_at(n, rates, epsilon, prefer, reps, seed, planned_n):
|
|
28
|
+
rng = random.Random(seed)
|
|
29
|
+
arms = list(rates)
|
|
30
|
+
base, tested = arms[0], arms[-1]
|
|
31
|
+
fixed = anytime = 0
|
|
32
|
+
for _ in range(reps):
|
|
33
|
+
d = analytic(simulate(n, rng, rates, epsilon, prefer), arms, base, t_star=planned_n)[tested]["dr"]
|
|
34
|
+
fixed += d["diff_lo"] > 0 or d["diff_hi"] < 0
|
|
35
|
+
anytime += d["cs_lo"] > 0 or d["cs_hi"] < 0
|
|
36
|
+
return fixed / reps, anytime / reps
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plan(base_rate, effect, arms=2, epsilon=0.1, prefer=0.0, grid=None, reps=200, seed=0, target=0.8):
|
|
40
|
+
"""Power table over `grid` decision counts; the second element is the smallest n reaching `target`."""
|
|
41
|
+
if not 0 < base_rate < 1 or not 0 < base_rate + effect < 1:
|
|
42
|
+
raise ValueError("base rate and base rate + effect must both lie strictly between 0 and 1")
|
|
43
|
+
if arms < 2:
|
|
44
|
+
raise ValueError("need at least two arms")
|
|
45
|
+
names = [f"arm{i}" for i in range(arms)]
|
|
46
|
+
rates = {a: base_rate for a in names}
|
|
47
|
+
rates[names[-1]] = base_rate + effect
|
|
48
|
+
grid = grid or [500, 1000, 2000, 4000, 8000, 16000]
|
|
49
|
+
if any(n < 10 for n in grid):
|
|
50
|
+
raise ValueError("every value in the grid must be at least 10 decisions")
|
|
51
|
+
if reps < 1:
|
|
52
|
+
raise ValueError("reps must be at least 1")
|
|
53
|
+
grid = sorted(set(grid))
|
|
54
|
+
planned = max(grid) // 2
|
|
55
|
+
table = []
|
|
56
|
+
for n in grid:
|
|
57
|
+
fixed, anytime = power_at(n, rates, epsilon, prefer, reps, seed, planned)
|
|
58
|
+
table.append({"n": n, "power_fixed": fixed, "power_anytime": anytime})
|
|
59
|
+
reach = lambda key: next((r["n"] for r in table if r[key] >= target), None)
|
|
60
|
+
return {"base_rate": base_rate, "effect": effect, "arms": arms, "epsilon": epsilon, "prefer": prefer,
|
|
61
|
+
"reps": reps, "target": target, "planned_n": planned, "table": table,
|
|
62
|
+
"n_for_target_fixed": reach("power_fixed"), "n_for_target_anytime": reach("power_anytime")}
|
ledger/policy.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Stochastic arm selection with logged propensities.
|
|
2
|
+
|
|
3
|
+
Randomisation happens in the selection step, never in LLM sampling, so every
|
|
4
|
+
arm has a known, strictly positive probability. That is what makes IPW and
|
|
5
|
+
doubly-robust estimates of cause and effect possible later.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import random
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def propensities(scores, epsilon=0.1, temperature=1.0):
|
|
15
|
+
"""p(a) = (1 - eps) * softmax(score / temperature) + eps / k."""
|
|
16
|
+
if not scores:
|
|
17
|
+
raise ValueError("need at least one arm")
|
|
18
|
+
if not 0 < epsilon <= 1:
|
|
19
|
+
raise ValueError("epsilon must be in (0, 1] so every arm stays possible")
|
|
20
|
+
k = len(scores)
|
|
21
|
+
top = max(s / temperature for s in scores)
|
|
22
|
+
exp = [math.exp(s / temperature - top) for s in scores]
|
|
23
|
+
total = sum(exp)
|
|
24
|
+
return [(1 - epsilon) * e / total + epsilon / k for e in exp]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def record_decision(con, arms, scores=None, context=None, epsilon=0.1, temperature=1.0,
|
|
28
|
+
policy_version="v1", ts=None, rng=None, decision_id=None):
|
|
29
|
+
"""Choose an arm, write the decision with all propensities, and return it."""
|
|
30
|
+
rng = rng or random.Random()
|
|
31
|
+
scores = scores if scores is not None else [0.0] * len(arms)
|
|
32
|
+
if len(scores) != len(arms):
|
|
33
|
+
raise ValueError("scores and arms must have the same length")
|
|
34
|
+
probs = propensities(scores, epsilon, temperature)
|
|
35
|
+
chosen = rng.choices(arms, weights=probs)[0]
|
|
36
|
+
decision = {
|
|
37
|
+
"id": decision_id or uuid.UUID(int=rng.getrandbits(128), version=4).hex,
|
|
38
|
+
"ts": int(ts if ts is not None else time.time()),
|
|
39
|
+
"policy_version": policy_version,
|
|
40
|
+
"context": context or {},
|
|
41
|
+
"arms": list(arms),
|
|
42
|
+
"propensities": dict(zip(arms, probs)),
|
|
43
|
+
"chosen": chosen,
|
|
44
|
+
"propensity": probs[arms.index(chosen)],
|
|
45
|
+
}
|
|
46
|
+
con.execute(
|
|
47
|
+
"INSERT INTO decisions VALUES (?,?,?,?,?,?,?,?)",
|
|
48
|
+
(decision["id"], decision["ts"], policy_version, json.dumps(decision["context"]),
|
|
49
|
+
json.dumps(decision["arms"]), json.dumps(decision["propensities"]), chosen,
|
|
50
|
+
decision["propensity"]),
|
|
51
|
+
)
|
|
52
|
+
return decision
|
ledger/report.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Render estimate results as a self-contained HTML report."""
|
|
2
|
+
import datetime as dt
|
|
3
|
+
import html
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
NAMES = {"naive": "Naive", "ipw": "IPW", "snipw": "SNIPW", "dm": "Direct", "dr": "Doubly robust"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def pct(x, digits=1, sign=False):
|
|
10
|
+
if x is None or (isinstance(x, float) and math.isnan(x)):
|
|
11
|
+
return "–"
|
|
12
|
+
s = f"{x * 100:+.{digits}f}" if sign else f"{x * 100:.{digits}f}"
|
|
13
|
+
return s + (" pp" if sign else "%")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ci(e, sign=False):
|
|
17
|
+
return f'{pct(e["value"], sign=sign)} <span class="ci">[{pct(e["lo"], sign=sign)}, {pct(e["hi"], sign=sign)}]</span>'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def anytime_note(est, arm):
|
|
21
|
+
"""Whether the difference also holds under the anytime-valid confidence sequence."""
|
|
22
|
+
a = est.get("analytic", {}).get(arm, {}).get("dr", {})
|
|
23
|
+
if "cs_lo" not in a or math.isnan(a["cs_lo"]):
|
|
24
|
+
return ""
|
|
25
|
+
if a["cs_lo"] > 0 or a["cs_hi"] < 0:
|
|
26
|
+
return ' <span class="tag ok">holds even if you check this report every week</span>'
|
|
27
|
+
return (' <span class="tag">not yet safe to act on if you have been checking repeatedly '
|
|
28
|
+
f'(anytime-valid range {pct(a["cs_lo"], sign=True)} to {pct(a["cs_hi"], sign=True)})</span>')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def verdicts(est, primary):
|
|
32
|
+
base = est["baseline"]
|
|
33
|
+
lines = []
|
|
34
|
+
for arm, entry in est["arms"].items():
|
|
35
|
+
if arm == base:
|
|
36
|
+
continue
|
|
37
|
+
d = entry["vs_baseline"][primary]
|
|
38
|
+
name, bname = html.escape(arm), html.escape(base)
|
|
39
|
+
if est["diagnostics"][arm]["n"] == 0:
|
|
40
|
+
lines.append(f'<li class="even"><b>{name}</b> was never chosen, so there is nothing to compare with {bname}.</li>')
|
|
41
|
+
continue
|
|
42
|
+
note = anytime_note(est, arm) if d["lo"] > 0 or d["hi"] < 0 else ""
|
|
43
|
+
size = f'{abs(d["value"]) * 100:.1f} pp'
|
|
44
|
+
interval = f'95% CI {pct(d["lo"], sign=True)} to {pct(d["hi"], sign=True)}'
|
|
45
|
+
if d["lo"] > 0:
|
|
46
|
+
lines.append(f'<li class="worse"><b>{name}</b> causes <b>{size}</b> more bad outcomes than {bname} '
|
|
47
|
+
f'({interval}).{note}</li>')
|
|
48
|
+
elif d["hi"] < 0:
|
|
49
|
+
lines.append(f'<li class="better"><b>{name}</b> causes <b>{size}</b> fewer bad outcomes than {bname} '
|
|
50
|
+
f'({interval}).{note}</li>')
|
|
51
|
+
else:
|
|
52
|
+
lines.append(f'<li class="even"><b>{name}</b> vs {bname}: no clear difference yet '
|
|
53
|
+
f'({pct(d["value"], sign=True)}, 95% CI {pct(d["lo"], sign=True)} to {pct(d["hi"], sign=True)}).</li>')
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def forest(est, primary, truth=None):
|
|
58
|
+
arms = list(est["arms"])
|
|
59
|
+
vals = []
|
|
60
|
+
unused = {a for a in arms if est["diagnostics"][a]["n"] == 0}
|
|
61
|
+
for a in arms:
|
|
62
|
+
if a in unused:
|
|
63
|
+
continue
|
|
64
|
+
for e in ("naive", primary):
|
|
65
|
+
x = est["arms"][a]["estimates"][e]
|
|
66
|
+
vals += [x["lo"], x["hi"], x["value"]]
|
|
67
|
+
if truth and a in truth:
|
|
68
|
+
vals.append(truth[a])
|
|
69
|
+
vals = [v for v in vals if not math.isnan(v)] or [0.0, 0.1]
|
|
70
|
+
lo, hi = max(0.0, min(vals) - 0.01), max(vals) + 0.01
|
|
71
|
+
W, left, right, row = 640, 110, 24, 56
|
|
72
|
+
H = 40 + row * len(arms)
|
|
73
|
+
sx = lambda v: left + (v - lo) / (hi - lo) * (W - left - right)
|
|
74
|
+
parts = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Estimated bad-outcome rate per arm">']
|
|
75
|
+
step = 0.01 if hi - lo < 0.12 else 0.02 if hi - lo < 0.25 else 0.05
|
|
76
|
+
t = math.ceil(lo / step) * step
|
|
77
|
+
while t <= hi + 1e-9:
|
|
78
|
+
x = sx(t)
|
|
79
|
+
parts.append(f'<line x1="{x:.1f}" x2="{x:.1f}" y1="16" y2="{H - 20}" class="grid"/>'
|
|
80
|
+
f'<text x="{x:.1f}" y="{H - 4}" class="tick">{t * 100:.0f}%</text>')
|
|
81
|
+
t += step
|
|
82
|
+
for i, a in enumerate(arms):
|
|
83
|
+
y = 34 + i * row
|
|
84
|
+
parts.append(f'<text x="{left - 12}" y="{y + 10}" class="arm">{html.escape(a)}</text>')
|
|
85
|
+
if a in unused:
|
|
86
|
+
parts.append(f'<text x="{left + 8}" y="{y + 10}" class="tick" style="text-anchor:start">never chosen: no data</text>')
|
|
87
|
+
continue
|
|
88
|
+
for j, (e, cls) in enumerate((("naive", "naive"), (primary, "primary"))):
|
|
89
|
+
x = est["arms"][a]["estimates"][e]
|
|
90
|
+
yy = y + j * 18
|
|
91
|
+
parts.append(f'<line x1="{sx(x["lo"]):.1f}" x2="{sx(x["hi"]):.1f}" y1="{yy}" y2="{yy}" class="ci-{cls}"/>'
|
|
92
|
+
f'<circle cx="{sx(x["value"]):.1f}" cy="{yy}" r="5" class="pt-{cls}">'
|
|
93
|
+
f'<title>{NAMES[e]}: {x["value"] * 100:.1f}% [{x["lo"] * 100:.1f}, {x["hi"] * 100:.1f}]</title></circle>')
|
|
94
|
+
if truth and a in truth:
|
|
95
|
+
x = sx(truth[a])
|
|
96
|
+
parts.append(f'<path d="M{x:.1f} {y - 9} l5 -7 h-10 z" class="truth"><title>True rate: {truth[a] * 100:.1f}%</title></path>')
|
|
97
|
+
parts.append("</svg>")
|
|
98
|
+
return "".join(parts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
CSS = """
|
|
102
|
+
:root{--bg:#f7f6f2;--card:#fff;--ink:#1d1c1a;--muted:#6b6862;--line:#e3e0d8;--accent:#2f5bd3;--naive:#a8a39a;
|
|
103
|
+
--good:#1f7a4d;--bad:#b3261e;--truth:#d98a00}
|
|
104
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#151515;--card:#1e1e1e;--ink:#ecebe7;--muted:#a19d95;
|
|
105
|
+
--line:#34322e;--accent:#7ea1ff;--naive:#77736b;--good:#5fc48d;--bad:#ff8a80;--truth:#ffb74d}}
|
|
106
|
+
:root[data-theme="dark"]{--bg:#151515;--card:#1e1e1e;--ink:#ecebe7;--muted:#a19d95;--line:#34322e;--accent:#7ea1ff;
|
|
107
|
+
--naive:#77736b;--good:#5fc48d;--bad:#ff8a80;--truth:#ffb74d}
|
|
108
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
109
|
+
main{max-width:920px;margin:0 auto;padding:32px 16px 64px}h1{font-size:28px;margin:0 0 4px;letter-spacing:-.01em}
|
|
110
|
+
h2{font-size:17px;margin:32px 0 10px}.sub{color:var(--muted);margin:0 0 24px}
|
|
111
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:18px 20px;margin:12px 0}
|
|
112
|
+
ul.verdict{list-style:none;padding:0;margin:0}ul.verdict li{padding:8px 0 8px 14px;border-left:3px solid var(--line);margin:6px 0}
|
|
113
|
+
li.worse{border-color:var(--bad)!important}li.better{border-color:var(--good)!important}
|
|
114
|
+
.table{overflow-x:auto}table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums;font-size:14px}
|
|
115
|
+
th,td{text-align:right;padding:7px 10px;border-bottom:1px solid var(--line);white-space:nowrap}th:first-child,td:first-child{text-align:left}
|
|
116
|
+
th{color:var(--muted);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em}
|
|
117
|
+
.ci{color:var(--muted);font-size:12px}.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px}
|
|
118
|
+
.kpi{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px}
|
|
119
|
+
.kpi b{display:block;font-size:22px}.kpi span{color:var(--muted);font-size:13px}
|
|
120
|
+
svg{width:100%;height:auto;display:block}.grid{stroke:var(--line)}.tick{fill:var(--muted);font-size:11px;text-anchor:middle}
|
|
121
|
+
.arm{fill:var(--ink);font-size:13px;text-anchor:end;font-weight:600}
|
|
122
|
+
.ci-naive{stroke:var(--naive);stroke-width:2}.pt-naive{fill:var(--card);stroke:var(--naive);stroke-width:2}
|
|
123
|
+
.ci-primary{stroke:var(--accent);stroke-width:3}.pt-primary{fill:var(--accent)}.truth{fill:var(--truth)}
|
|
124
|
+
.legend{display:flex;gap:18px;flex-wrap:wrap;color:var(--muted);font-size:13px;margin-top:6px}
|
|
125
|
+
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:-1px}
|
|
126
|
+
.note{color:var(--muted);font-size:13.5px}code{font-size:13px}
|
|
127
|
+
.tag{display:inline-block;margin-left:6px;padding:1px 8px;border-radius:999px;font-size:12px;color:var(--muted);
|
|
128
|
+
border:1px solid var(--line)}.tag.ok{color:var(--good);border-color:var(--good)}
|
|
129
|
+
.warn{border-left:4px solid var(--truth)}.warn ul{margin:8px 0 0;padding-left:20px}.warn li{margin:4px 0}
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_audit(result, repo_name="", title="Consequence Ledger audit"):
|
|
134
|
+
as_of = dt.datetime.fromtimestamp(result["as_of"], dt.timezone.utc).strftime("%Y-%m-%d")
|
|
135
|
+
tools = result["tools"]
|
|
136
|
+
rates = [v for t in tools.values() for v in (t["ci_lo"], t["ci_hi"], t["size_adjusted_rate"]) if not math.isnan(v)]
|
|
137
|
+
hi = max(rates + [0.05]) * 1.1
|
|
138
|
+
W, left, right, row = 640, 110, 24, 40
|
|
139
|
+
H = 30 + row * len(tools)
|
|
140
|
+
sx = lambda v: left + v / hi * (W - left - right)
|
|
141
|
+
svg = [f'<svg viewBox="0 0 {W} {H}" role="img" aria-label="Bad-outcome rate per tool">']
|
|
142
|
+
step = 0.01 if hi < 0.12 else 0.02 if hi < 0.25 else 0.05
|
|
143
|
+
t = 0.0
|
|
144
|
+
while t <= hi:
|
|
145
|
+
svg.append(f'<line x1="{sx(t):.1f}" x2="{sx(t):.1f}" y1="8" y2="{H - 20}" class="grid"/>'
|
|
146
|
+
f'<text x="{sx(t):.1f}" y="{H - 4}" class="tick">{t * 100:.0f}%</text>')
|
|
147
|
+
t += step
|
|
148
|
+
for i, (name, d) in enumerate(tools.items()):
|
|
149
|
+
y = 24 + i * row
|
|
150
|
+
svg.append(f'<text x="{left - 12}" y="{y + 4}" class="arm">{html.escape(name)}</text>'
|
|
151
|
+
f'<line x1="{sx(d["ci_lo"]):.1f}" x2="{sx(d["ci_hi"]):.1f}" y1="{y}" y2="{y}" class="ci-primary"/>'
|
|
152
|
+
f'<circle cx="{sx(d["bad_rate"]):.1f}" cy="{y}" r="5" class="pt-primary">'
|
|
153
|
+
f'<title>{d["bad_rate"] * 100:.1f}% of {d["commits"]} commits</title></circle>')
|
|
154
|
+
if not math.isnan(d["size_adjusted_rate"]):
|
|
155
|
+
svg.append(f'<circle cx="{sx(d["size_adjusted_rate"]):.1f}" cy="{y}" r="5" class="pt-naive">'
|
|
156
|
+
f'<title>Size-adjusted: {d["size_adjusted_rate"] * 100:.1f}%</title></circle>')
|
|
157
|
+
svg.append("</svg>")
|
|
158
|
+
rows = "".join(
|
|
159
|
+
f"<tr><td>{html.escape(n)}</td><td>{d['commits']}</td><td>{d['reverted']}</td><td>{d['fixed']}</td>"
|
|
160
|
+
f"<td>{pct(d['bad_rate'])} <span class=ci>[{pct(d['ci_lo'])}, {pct(d['ci_hi'])}]</span></td>"
|
|
161
|
+
f"<td>{pct(d['size_adjusted_rate'])}</td></tr>" for n, d in tools.items())
|
|
162
|
+
detection = "".join(f'<div class="card note"><b>Possible undercount.</b> {html.escape(n)}</div>'
|
|
163
|
+
for n in result.get("detection_notes", ()))
|
|
164
|
+
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
165
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title>
|
|
166
|
+
<style>{CSS}</style></head><body><main>
|
|
167
|
+
<h1>{html.escape(title)}</h1>
|
|
168
|
+
<p class="sub">Share of each tool's commits reverted or bug-fixed within {result['horizon_days']} days.
|
|
169
|
+
As of {as_of}{' · ' + html.escape(repo_name) if repo_name else ''} · {result['mature_commits']} mature commits.</p>
|
|
170
|
+
<div class="card note"><b>This is a baseline, not proof.</b> Tools get used on different work, so these gaps mix the tool's
|
|
171
|
+
effect with the task's difficulty. Size adjustment removes one confounder. To get cause and effect, route work with
|
|
172
|
+
<code>ledger decide</code> and compare with <code>ledger estimate</code>.</div>
|
|
173
|
+
{detection}<h2>Bad-outcome rate by tool</h2>
|
|
174
|
+
<div class="card">{''.join(svg)}<div class="legend"><span><i class="dot" style="background:var(--accent)"></i>Raw rate, 95% CI</span>
|
|
175
|
+
<span><i class="dot" style="background:var(--naive)"></i>Size-adjusted</span></div></div>
|
|
176
|
+
<div class="card table"><table><thead><tr><th>Tool</th><th>Commits</th><th>Reverted</th><th>Fixed</th><th>Bad rate [95% CI]</th>
|
|
177
|
+
<th>Size-adjusted</th></tr></thead><tbody>{rows}</tbody></table></div>
|
|
178
|
+
</main></body></html>"""
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def warnings(est):
|
|
182
|
+
"""Plain-language checks that would undermine the estimates if ignored."""
|
|
183
|
+
out = []
|
|
184
|
+
bal = est.get("merge_balance")
|
|
185
|
+
if bal and bal.get("p") is not None and bal["p"] < 0.05:
|
|
186
|
+
rates = ", ".join(f"{html.escape(a)} {pct(v['rate'])}" for a, v in bal["arms"].items())
|
|
187
|
+
out.append(f"<li><b>Unmerged work differs by arm</b> ({rates}; p = {bal['p']:.3f}). Estimates only cover merged "
|
|
188
|
+
f"changes, so an arm whose weak changes get abandoned will look better than it is.</li>")
|
|
189
|
+
for a, d in est["diagnostics"].items():
|
|
190
|
+
n, bad, name = d["n"], d["bad"], html.escape(a)
|
|
191
|
+
if n == 0:
|
|
192
|
+
out.append(f"<li><b>{name}</b> was never chosen, so no estimate exists for it.</li>")
|
|
193
|
+
continue
|
|
194
|
+
if bad == 0:
|
|
195
|
+
out.append(f"<li><b>{name}</b> had no bad outcomes in {n} decisions. That is consistent with a true rate of up "
|
|
196
|
+
f"to about {min(1.0, 3 / n) * 100:.0f}% (\"rule of three\"), so its interval is narrower than the data justify.</li>")
|
|
197
|
+
elif bad == n:
|
|
198
|
+
out.append(f"<li><b>{name}</b> had only bad outcomes in {n} decisions, so its interval is narrower than the "
|
|
199
|
+
f"data justify.</li>")
|
|
200
|
+
elif bad < 5:
|
|
201
|
+
out.append(f"<li><b>{name}</b> has only {bad} bad outcomes, too few for reliable intervals.</li>")
|
|
202
|
+
if d["min_propensity"] < 0.02:
|
|
203
|
+
out.append(f"<li><b>{html.escape(a)}</b> was almost never chosen for some tasks (lowest probability "
|
|
204
|
+
f"{d['min_propensity'] * 100:.1f}%), so a few decisions carry weights up to {d['max_weight']:.0f}. "
|
|
205
|
+
f"Raise exploration (<code>--epsilon</code>) or cap weights (<code>--clip</code>).</li>")
|
|
206
|
+
if 0 < d["ess"] < 30:
|
|
207
|
+
out.append(f"<li><b>{html.escape(a)}</b> has an effective sample size of only {d['ess']:.0f}; "
|
|
208
|
+
f"treat its numbers as rough.</li>")
|
|
209
|
+
if not out:
|
|
210
|
+
return ""
|
|
211
|
+
return f'<div class="card warn"><b>Check before trusting these numbers</b><ul>{"".join(out)}</ul></div>'
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def early_card(est, label_stats):
|
|
215
|
+
early = est.get("early")
|
|
216
|
+
if not early:
|
|
217
|
+
return ""
|
|
218
|
+
days = label_stats.get("early_days") or "early"
|
|
219
|
+
cal = early.get("calibration")
|
|
220
|
+
pairs = cal["pairs"] if cal else {}
|
|
221
|
+
head = "<th>Arm</th><th>Early difference [95% CI]</th>" + (
|
|
222
|
+
"<th>Same decisions: early</th><th>Same decisions: final</th>" if cal else "")
|
|
223
|
+
rows = "".join(
|
|
224
|
+
f"<tr><td>{html.escape(a)}</td><td>{pct(v['diff'], sign=True)} <span class=ci>[{pct(v['diff_lo'], sign=True)}, "
|
|
225
|
+
f"{pct(v['diff_hi'], sign=True)}]</span></td>"
|
|
226
|
+
+ (f"<td>{pct(pairs[a]['early_diff'], sign=True)}</td><td>{pct(pairs[a]['final_diff'], sign=True)}</td>"
|
|
227
|
+
if a in pairs else "")
|
|
228
|
+
+ "</tr>"
|
|
229
|
+
for a, v in early["arms"].items() if a != early["baseline"])
|
|
230
|
+
if cal:
|
|
231
|
+
share = cal["surfaced_early"]
|
|
232
|
+
weak = " That is too few for the early numbers to say much on their own." if share < 0.4 else ""
|
|
233
|
+
trust = (f"Only {pct(share, 0)} of the final bad outcomes had surfaced by day {days} on the {cal['n']} decisions "
|
|
234
|
+
f"old enough to have both labels, so early differences are a muted version of final ones.{weak} "
|
|
235
|
+
f"The last two columns show how they compared on those decisions; use them to judge the first column.")
|
|
236
|
+
else:
|
|
237
|
+
trust = "No decisions are old enough yet to check how well early signals predict the final outcome; treat this as a hint."
|
|
238
|
+
return (f'<h2>Early signal: bad within {days} days</h2><div class="card"><p class="note">Covers {early["n"]} decisions, '
|
|
239
|
+
f'including recent ones the main estimate must still exclude. Difference vs {html.escape(early["baseline"])}, '
|
|
240
|
+
f'doubly robust:</p><div class="table"><table><thead><tr>{head}</tr></thead>'
|
|
241
|
+
f'<tbody>{rows}</tbody></table></div><p class="note">{trust}</p></div>')
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def incident_rows(links):
|
|
245
|
+
names = {"suspect": "Suspect commit in the export", "files_off": "Files only, not linked (default)",
|
|
246
|
+
"files_latest": "Files only, latest matching deploy", "files_window": "Files only, every match in 14 days"}
|
|
247
|
+
return "".join(
|
|
248
|
+
f"<tr><td>{names.get(k, html.escape(k))}</td><td>{v['incidents']}</td><td>{pct(v['culprit_found'], 0)}</td>"
|
|
249
|
+
f"<td>{v['avg_commits_blamed']:.1f}</td></tr>" for k, v in links.items())
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def validation_card(v):
|
|
253
|
+
if not v:
|
|
254
|
+
return ""
|
|
255
|
+
links = v.get("incident_linking")
|
|
256
|
+
incident_table = (
|
|
257
|
+
'<p>How incidents were tied to the change that caused them:</p><div class="table"><table><thead><tr>'
|
|
258
|
+
'<th>Link</th><th>Incidents</th><th>Culprit found</th><th>Commits blamed</th></tr></thead>'
|
|
259
|
+
f'<tbody>{incident_rows(links)}</tbody></table></div>' if links else "")
|
|
260
|
+
oracle = ", ".join(f"{html.escape(a)} {pct(e['value'])}" for a, e in v["oracle_dr"].items())
|
|
261
|
+
return (f'<h2>Label check against the known truth</h2><div class="card note">'
|
|
262
|
+
f'<p>Of the decisions the labeler marked bad, <b>{pct(v["precision"], 0)}</b> really were (precision). '
|
|
263
|
+
f'Of the truly bad ones, it caught <b>{pct(v["recall"], 0)}</b> (recall). '
|
|
264
|
+
f'{v["fp"]} false alarms (fixes that touched these lines for unrelated reasons) and '
|
|
265
|
+
f'{v["fn"]} misses.</p>'
|
|
266
|
+
f'<p>Doubly robust estimates using perfect labels instead: {oracle}. The gap between these and the '
|
|
267
|
+
f'table above is labeling noise; the gap between these and the true rates is estimator error.</p>'
|
|
268
|
+
f'{incident_table}</div>')
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def render(est, label_stats, title="Consequence Ledger", primary="dr", truth=None, meta=None, validation=None):
|
|
272
|
+
meta = meta or {}
|
|
273
|
+
as_of = dt.datetime.fromtimestamp(label_stats["as_of"], dt.timezone.utc).strftime("%Y-%m-%d")
|
|
274
|
+
rows = []
|
|
275
|
+
for a, entry in est["arms"].items():
|
|
276
|
+
d = est["diagnostics"][a]
|
|
277
|
+
e = entry["estimates"]
|
|
278
|
+
vs = entry["vs_baseline"][primary]
|
|
279
|
+
rows.append(
|
|
280
|
+
f"<tr><td>{html.escape(a)}{' <span class=ci>(baseline)</span>' if a == est['baseline'] else ''}</td>"
|
|
281
|
+
f"<td>{d['n']}</td><td>{d['bad']}</td><td>{pct(e['naive']['value'])}</td>"
|
|
282
|
+
f"<td>{pct(e['ipw']['value'])}</td><td>{pct(e['snipw']['value'])}</td><td>{ci(e[primary])}</td>"
|
|
283
|
+
f"<td>{'–' if a == est['baseline'] else ci(vs, sign=True)}</td>"
|
|
284
|
+
f"<td>{d['ess']:.0f}</td>"
|
|
285
|
+
+ (f"<td>{pct(truth[a])}</td>" if truth and a in truth else "")
|
|
286
|
+
+ "</tr>")
|
|
287
|
+
truth_head = "<th>True</th>" if truth else ""
|
|
288
|
+
total = label_stats.get("mature", 0) + label_stats.get("immature", 0) + label_stats.get("no_commit", 0)
|
|
289
|
+
kpis = [
|
|
290
|
+
(total, "decisions logged"),
|
|
291
|
+
(label_stats.get("mature", 0), f"mature (≥{label_stats['horizon_days']} days old)"),
|
|
292
|
+
(label_stats.get("revert", 0), "reverted in horizon"),
|
|
293
|
+
(label_stats.get("fix", 0), "fixed in horizon (SZZ)"),
|
|
294
|
+
(label_stats.get("incident", 0), "linked to an incident"),
|
|
295
|
+
(label_stats.get("no_commit", 0), "never merged"),
|
|
296
|
+
]
|
|
297
|
+
legend = ('<div class="legend"><span><i class="dot" style="background:var(--naive)"></i>Naive (biased by routing)</span>'
|
|
298
|
+
f'<span><i class="dot" style="background:var(--accent)"></i>{NAMES[primary]} (causal)</span>'
|
|
299
|
+
+ ('<span><i class="dot" style="background:var(--truth)"></i>True rate (synthetic demo)</span>' if truth else "")
|
|
300
|
+
+ "</div>")
|
|
301
|
+
source = html.escape(meta.get("repo", ""))
|
|
302
|
+
clusters = est.get("clusters", est["n"])
|
|
303
|
+
cluster_note = f", resampling {clusters} clusters of related decisions" if clusters < est["n"] else ""
|
|
304
|
+
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
305
|
+
<meta name="viewport" content="width=device-width,initial-scale=1"><title>{html.escape(title)}</title>
|
|
306
|
+
<style>{CSS}</style></head><body><main>
|
|
307
|
+
<h1>{html.escape(title)}</h1>
|
|
308
|
+
<p class="sub">Which AI arm <i>causes</i> more reverted or bug-fixed code within {label_stats['horizon_days']} days of merge. As of {as_of}{' · ' + source if source else ''}.</p>
|
|
309
|
+
<div class="card"><ul class="verdict">{verdicts(est, primary)}</ul></div>
|
|
310
|
+
{warnings(est)}
|
|
311
|
+
<div class="kpis">{''.join(f'<div class="kpi"><b>{v}</b><span>{k}</span></div>' for v, k in kpis)}</div>
|
|
312
|
+
<h2>Bad-outcome rate if every task had gone to this arm</h2>
|
|
313
|
+
<div class="card">{forest(est, primary, truth)}{legend}</div>
|
|
314
|
+
<h2>Estimates</h2>
|
|
315
|
+
<div class="card table"><table><thead><tr><th>Arm</th><th>Decisions</th><th>Bad</th><th>Naive</th><th>IPW</th><th>SNIPW</th>
|
|
316
|
+
<th>{NAMES[primary]} [95% CI]</th><th>vs baseline</th><th>ESS</th>{truth_head}</tr></thead><tbody>{''.join(rows)}</tbody></table></div>
|
|
317
|
+
{early_card(est, label_stats)}
|
|
318
|
+
{validation_card(validation)}
|
|
319
|
+
<h2>How to read this</h2>
|
|
320
|
+
<div class="card note">
|
|
321
|
+
<p><b>Bad</b> means the merged commit was reverted, or a later <code>fix:</code>/<code>hotfix:</code> commit changed its lines (SZZ via
|
|
322
|
+
<code>git blame</code>), within {label_stats['horizon_days']} days. Only decisions whose full window has elapsed are counted, so recent
|
|
323
|
+
work can't look clean just because its bugs haven't surfaced yet.</p>
|
|
324
|
+
<p><b>Naive</b> compares raw rates and is misleading when harder tasks are routed to one tool. <b>IPW</b>, <b>SNIPW</b> and
|
|
325
|
+
<b>doubly robust</b> use the probability logged at decision time to undo that routing, which turns correlation into cause and
|
|
326
|
+
effect. Intervals are {est['boot']}-sample bootstrap 95% CIs{cluster_note}. <b>ESS</b> is the effective sample size after weighting; low values mean noisy estimates.</p>
|
|
327
|
+
<p>SZZ labels are noisy (false positives from unrelated fixes), but that noise hits every arm alike, so differences stay meaningful.
|
|
328
|
+
Decisions that were never merged ({label_stats.get('no_commit', 0)}) are excluded; check they are spread evenly across arms.</p>
|
|
329
|
+
</div>
|
|
330
|
+
</main></body></html>"""
|