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/db.py ADDED
@@ -0,0 +1,70 @@
1
+ """SQLite storage for decisions, commit attribution and outcome labels."""
2
+ import sqlite3
3
+ from pathlib import Path
4
+
5
+ SCHEMA = """
6
+ CREATE TABLE IF NOT EXISTS decisions(
7
+ id TEXT PRIMARY KEY,
8
+ ts INTEGER NOT NULL, -- unix seconds when the arm was chosen
9
+ policy_version TEXT NOT NULL,
10
+ context TEXT NOT NULL, -- JSON object; "stratum" is used by the estimators
11
+ arms TEXT NOT NULL, -- JSON list of candidate arms
12
+ propensities TEXT NOT NULL, -- JSON {arm: probability} for every candidate
13
+ chosen TEXT NOT NULL,
14
+ propensity REAL NOT NULL -- probability of the chosen arm
15
+ );
16
+ CREATE TABLE IF NOT EXISTS commits(
17
+ sha TEXT PRIMARY KEY,
18
+ decision_id TEXT NOT NULL REFERENCES decisions(id),
19
+ ts INTEGER,
20
+ source TEXT NOT NULL -- 'trailer' or 'manual'
21
+ );
22
+ CREATE TABLE IF NOT EXISTS szz_cache(
23
+ fix_sha TEXT NOT NULL,
24
+ key TEXT NOT NULL, -- algorithm version + skip rules + ignore list
25
+ introducers TEXT NOT NULL, -- JSON list of commits whose lines the fix changed
26
+ PRIMARY KEY (fix_sha, key)
27
+ );
28
+ CREATE TABLE IF NOT EXISTS outcomes(
29
+ decision_id TEXT PRIMARY KEY REFERENCES decisions(id),
30
+ bad INTEGER NOT NULL, -- 1 if reverted or fixed within the horizon
31
+ mature INTEGER NOT NULL, -- 1 if the full horizon has elapsed
32
+ kind TEXT NOT NULL, -- 'revert' | 'fix' | 'clean' | 'no_commit'
33
+ event_sha TEXT,
34
+ delay_days REAL,
35
+ horizon_days INTEGER NOT NULL,
36
+ as_of INTEGER NOT NULL,
37
+ early_bad INTEGER NOT NULL DEFAULT 0, -- bad within the early window (e.g. 7 days)
38
+ early_mature INTEGER NOT NULL DEFAULT 0, -- 1 if the early window has elapsed
39
+ early_days INTEGER
40
+ );
41
+ """
42
+
43
+
44
+ def db_path(repo):
45
+ return Path(repo) / ".ledger" / "ledger.db"
46
+
47
+
48
+ def connect(path):
49
+ path = Path(path)
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ ignore = path.parent / ".gitignore"
52
+ if path.parent.name == ".ledger" and not ignore.exists():
53
+ # The ledger must never be committed: it holds every decision and would churn on each run.
54
+ ignore.write_text("*\n")
55
+ con = sqlite3.connect(path)
56
+ con.row_factory = sqlite3.Row
57
+ con.executescript(SCHEMA)
58
+ migrate(con)
59
+ from .incidents import ensure_schema
60
+ ensure_schema(con)
61
+ return con
62
+
63
+
64
+ def migrate(con):
65
+ """Add columns introduced after a ledger was first created."""
66
+ have = {r["name"] for r in con.execute("PRAGMA table_info(outcomes)")}
67
+ for name, ddl in (("early_bad", "INTEGER NOT NULL DEFAULT 0"), ("early_mature", "INTEGER NOT NULL DEFAULT 0"),
68
+ ("early_days", "INTEGER")):
69
+ if name not in have:
70
+ con.execute(f"ALTER TABLE outcomes ADD COLUMN {name} {ddl}")
ledger/demo.py ADDED
@@ -0,0 +1,306 @@
1
+ """Build a synthetic repository with a known ground truth, then run the pipeline on it.
2
+
3
+ The world: three agents handle easy and hard tasks. The logging policy routes
4
+ hard tasks mostly to agent-B (the "strong" one), so naive comparisons blame B
5
+ for the hard tasks it was handed. The true effects are fixed in advance, so
6
+ the report shows whether the causal estimators recover them.
7
+
8
+ Git objects are written directly (zlib-compressed loose objects) so thousands
9
+ of commits with back-dated timestamps take seconds, not minutes.
10
+ """
11
+ import hashlib
12
+ import heapq
13
+ import json
14
+ import os
15
+ import random
16
+ import shutil
17
+ import stat
18
+ import sys
19
+ import zlib
20
+ from pathlib import Path
21
+
22
+ from .db import connect
23
+ from .gitio import git
24
+ from .policy import record_decision
25
+
26
+ DAY = 86400
27
+ ARMS = ["agent-A", "agent-B", "agent-C"]
28
+ STRATA = {"easy": 0.65, "hard": 0.35}
29
+ # Logging-policy scores: hard work is steered to agent-B.
30
+ SCORES = {"easy": [1.0, 0.2, 0.8], "hard": [0.2, 1.6, 0.1]}
31
+ # True probability that a decision's change goes bad (revert or bug fix) at some point.
32
+ P_BAD = {
33
+ ("easy", "agent-A"): 0.06, ("easy", "agent-B"): 0.03, ("easy", "agent-C"): 0.12,
34
+ ("hard", "agent-A"): 0.24, ("hard", "agent-B"): 0.20, ("hard", "agent-C"): 0.30,
35
+ }
36
+ P_IN_HORIZON = 0.85 # share of bad outcomes that surface within 30 days
37
+ P_REVERT = 0.45 # bad outcomes that are reverts (the rest are forward fixes)
38
+ P_UNMERGED = 0.06 # decisions whose change never merges
39
+ P_INCIDENT = 0.4 # forward-fix outcomes that instead surface as a production incident (no git trace)
40
+ DEVS = [("Asha Rao", "asha"), ("Ben Ode", "ben"), ("Chen Wu", "chen"), ("Dara Kim", "dara"),
41
+ ("Eli Moss", "eli"), ("Femi Ade", "femi")]
42
+
43
+
44
+ def rmtree(path):
45
+ # onexc replaced onerror in Python 3.12; both receive (func, path, error-ish).
46
+ if sys.version_info >= (3, 12):
47
+ shutil.rmtree(path, onexc=_force_remove)
48
+ else:
49
+ shutil.rmtree(path, onerror=_force_remove)
50
+
51
+
52
+ def _force_remove(func, path, _exc):
53
+ # Git marks pack files read-only, which blocks deletion on Windows.
54
+ os.chmod(path, stat.S_IWRITE)
55
+ func(path)
56
+
57
+
58
+ def true_rates(horizon_share=P_IN_HORIZON):
59
+ """E[Y(a)] over the task population: P(bad within the horizon | every task goes to a)."""
60
+ return {a: sum(w * P_BAD[(s, a)] for s, w in STRATA.items()) * horizon_share for a in ARMS}
61
+
62
+
63
+ class ObjectWriter:
64
+ """Minimal git object store writer for a flat tree of text files."""
65
+
66
+ def __init__(self, path):
67
+ self.path = Path(path)
68
+ git(path.parent, "init", "-q", "-b", "main", path.name)
69
+ self.objects = self.path / ".git" / "objects"
70
+ self.files = {}
71
+ self.blobs = {}
72
+ self.dirty = set()
73
+ self.head = None
74
+
75
+ def _write(self, kind, data):
76
+ raw = f"{kind} {len(data)}\0".encode() + data
77
+ sha = hashlib.sha1(raw).hexdigest()
78
+ target = self.objects / sha[:2] / sha[2:]
79
+ if not target.exists():
80
+ target.parent.mkdir(exist_ok=True)
81
+ target.write_bytes(zlib.compress(raw, 1))
82
+ return sha
83
+
84
+ def commit(self, message, ts, author):
85
+ for name in self.dirty:
86
+ self.blobs[name] = self._write("blob", ("\n".join(self.files[name]) + "\n").encode())
87
+ self.dirty.clear()
88
+ tree = b"".join(b"100644 " + n.encode() + b"\0" + bytes.fromhex(self.blobs[n])
89
+ for n in sorted(self.blobs))
90
+ tree_sha = self._write("tree", tree)
91
+ name, handle = author
92
+ ident = f"{name} <{handle}@example.test> {ts} +0000"
93
+ body = f"tree {tree_sha}\n" + (f"parent {self.head}\n" if self.head else "")
94
+ body += f"author {ident}\ncommitter {ident}\n\n{message.rstrip()}\n"
95
+ self.head = self._write("commit", body.encode())
96
+ return self.head
97
+
98
+ def finish(self):
99
+ (self.path / ".git" / "refs" / "heads" / "main").write_text(self.head + "\n")
100
+ git(self.path, "repack", "-a", "-d", "-q")
101
+ git(self.path, "prune")
102
+ git(self.path, "reset", "-q", "--hard")
103
+
104
+
105
+ def build(out, n=2400, days=240, seed=7, horizon_days=30, incidents=True):
106
+ """Create the repo and ledger. Returns (repo_path, as_of, truth).
107
+
108
+ With incidents on, the repo deploys daily and some bad changes show up only as production
109
+ incidents: half exported with a suspect commit (Sentry style), half with just the files
110
+ involved, so the ledger must find them through the deploy window.
111
+ """
112
+ rng = random.Random(seed)
113
+ # Incident choices use their own stream so the git history matches a run without incidents.
114
+ inc_rng = random.Random(seed + 1)
115
+ out = Path(out)
116
+ if out.exists() and any(out.iterdir()):
117
+ if not (out / ".ledger" / "truth.json").exists():
118
+ raise FileExistsError(f"{out} is not empty and is not a previous demo; pick another --out")
119
+ rmtree(out)
120
+ out.parent.mkdir(parents=True, exist_ok=True)
121
+ repo = ObjectWriter(out)
122
+ con = connect(out / ".ledger" / "ledger.db")
123
+ (out / ".git" / "info" / "exclude").write_text(".ledger/\n")
124
+
125
+ start = 1_746_057_600 # 2025-05-01 00:00 UTC
126
+ as_of = start + days * DAY
127
+
128
+ files = [f"src/mod_{i:02d}.py".replace("/", "_") for i in range(30)]
129
+ for f in files:
130
+ repo.files[f] = [f"# {f}", "def run(ctx):"] + [f" base_{f[:-3]}_{j}(ctx)" for j in range(30)]
131
+ repo.dirty.add(f)
132
+ repo.commit("chore: initial import", start - DAY, DEVS[0])
133
+
134
+ queue = [] # (ts, seq, kind, payload)
135
+ seq = 0
136
+
137
+ def push(ts, kind, payload):
138
+ nonlocal seq
139
+ heapq.heappush(queue, (int(ts), seq, kind, payload))
140
+ seq += 1
141
+
142
+ for i in range(n):
143
+ ts = start + rng.random() * (days - 2) * DAY
144
+ stratum = "easy" if rng.random() < STRATA["easy"] else "hard"
145
+ d = record_decision(con, ARMS, SCORES[stratum], {"stratum": stratum, "task": f"T-{i:05d}"},
146
+ epsilon=0.1, temperature=0.5, policy_version="demo-v1", ts=ts, rng=rng)
147
+ if rng.random() < P_UNMERGED:
148
+ continue
149
+ merge_ts = ts + rng.uniform(0.1, 2.0) * DAY
150
+ push(merge_ts, "decision", d)
151
+ if rng.random() < P_BAD[(stratum, d["chosen"])]:
152
+ delay = rng.uniform(1, horizon_days - 2) if rng.random() < P_IN_HORIZON else rng.uniform(horizon_days + 3, 90)
153
+ kind = "revert" if rng.random() < P_REVERT else "fix"
154
+ if incidents and kind == "fix" and inc_rng.random() < P_INCIDENT:
155
+ kind = "incident"
156
+ push(merge_ts + delay * DAY, kind, d["id"])
157
+ for i in range(n // 2):
158
+ push(start + rng.random() * days * DAY, "human", i)
159
+ if incidents:
160
+ for k in range(days + 1):
161
+ push(start + k * DAY + 18 * 3600, "deploy", k) # daily deploy at 18:00
162
+ con.commit()
163
+
164
+ placed = {} # decision id -> (file, lines, sha, subject, merge_ts)
165
+ first_event = {} # decision id -> seconds from merge to the first revert/fix/incident that happened
166
+ deploys, incident_rows = [], []
167
+ while queue:
168
+ ts, _, kind, payload = heapq.heappop(queue)
169
+ if ts > as_of:
170
+ continue
171
+ if kind == "deploy":
172
+ deploys.append((repo.head, ts))
173
+ continue
174
+ if kind == "incident":
175
+ if payload in placed:
176
+ f, lines, sha, subject, merged = placed[payload]
177
+ shipped = next((t for _, t in deploys if t >= merged), None)
178
+ if shipped is not None:
179
+ # An incident can only start once the change is in production.
180
+ inc_ts = max(ts, shipped + 3600)
181
+ by_suspect = inc_rng.random() < 0.5
182
+ incident_rows.append((f"INC-{len(incident_rows) + 1:04d}", inc_ts,
183
+ sha if by_suspect else "", "" if by_suspect else f, sha))
184
+ first_event.setdefault(payload, inc_ts - merged)
185
+ continue
186
+ dev = rng.choice(DEVS)
187
+ if kind == "decision":
188
+ d = payload
189
+ f = rng.choice(files)
190
+ lines = [f" step_{d['id'][:10]}_{j}(ctx)" for j in range(rng.randint(3, 9))]
191
+ pos = rng.randint(2, len(repo.files[f]))
192
+ repo.files[f][pos:pos] = lines
193
+ repo.dirty.add(f)
194
+ subject = f"feat: {d['context']['task']} {rng.choice(['add', 'extend', 'wire up', 'handle'])} {f[:-3]}"
195
+ sha = repo.commit(f"{subject}\n\nLedger-Decision-Id: {d['id']}", ts, dev)
196
+ placed[d["id"]] = (f, lines, sha, subject, ts)
197
+ elif kind in ("revert", "fix"):
198
+ if payload not in placed:
199
+ continue
200
+ f, lines, sha, subject, merged = placed[payload]
201
+ present = [ln for ln in lines if ln in repo.files[f]]
202
+ if not present:
203
+ continue
204
+ if kind == "revert":
205
+ repo.files[f] = [ln for ln in repo.files[f] if ln not in set(lines)]
206
+ msg = f'Revert "{subject}"\n\nThis reverts commit {sha}.'
207
+ else:
208
+ for ln in rng.sample(present, min(2, len(present))):
209
+ i = repo.files[f].index(ln)
210
+ repo.files[f][i] = ln + " # guard None ctx"
211
+ msg = f"fix: handle missing ctx in {f[:-3]}"
212
+ repo.dirty.add(f)
213
+ repo.commit(msg, ts, dev)
214
+ first_event.setdefault(payload, ts - merged)
215
+ else:
216
+ f = rng.choice(files)
217
+ i = rng.randint(2, len(repo.files[f]) - 1)
218
+ repo.files[f][i] = f" tidy_{payload}(ctx)"
219
+ repo.dirty.add(f)
220
+ noisy_fix = rng.random() < 0.12
221
+ repo.commit(f"{'fix: off-by-one' if noisy_fix else 'refactor: tidy'} in {f[:-3]}", ts, dev)
222
+ repo.finish()
223
+ if incidents:
224
+ from .incidents import import_deploys, import_incidents
225
+ led = out / ".ledger"
226
+ (led / "deploys.csv").write_text("sha,deployed_at\n" + "".join(f"{s},{t}\n" for s, t in deploys))
227
+ (led / "incidents.csv").write_text("id,started_at,commits,files\n"
228
+ + "".join(f"{i},{t},{c},{f}\n" for i, t, c, f, _ in incident_rows))
229
+ # The true culprit of each incident, kept apart from the export so the ledger never sees it.
230
+ (led / "oracle_incidents.json").write_text(json.dumps(
231
+ {i: {"culprit": culprit, "method": "suspect" if c else "deploy"} for i, t, c, f, culprit in incident_rows}))
232
+ import_deploys(con, led / "deploys.csv")
233
+ import_incidents(con, led / "incidents.csv")
234
+
235
+ truth = true_rates()
236
+ (out / ".ledger" / "truth.json").write_text(json.dumps(truth, indent=2))
237
+ # Oracle label per merged decision: did a revert/fix really land within the horizon?
238
+ oracle = {d: int(first_event.get(d, horizon_days * DAY + 1) <= horizon_days * DAY) for d in placed}
239
+ (out / ".ledger" / "oracle.json").write_text(json.dumps(oracle))
240
+ con.close()
241
+ return out, as_of, truth
242
+
243
+
244
+ def incident_accuracy(repo):
245
+ """Per linking method: how often the true culprit was blamed, and how many commits were blamed."""
246
+ from .gitio import log_commits
247
+ from .incidents import incident_events
248
+
249
+ path = Path(repo) / ".ledger" / "oracle_incidents.json"
250
+ if not path.exists():
251
+ return None
252
+ truth = json.loads(path.read_text())
253
+ commits = log_commits(repo)
254
+ con = connect(Path(repo) / ".ledger" / "ledger.db")
255
+ out = {}
256
+ for mode in ("off", "latest", "window"):
257
+ events, _ = incident_events(repo, con, commits, {c["sha"] for c in commits}, mode)
258
+ blamed = {}
259
+ for sha, evs in events.items():
260
+ for _, _, incident_id in evs:
261
+ blamed.setdefault(incident_id, set()).add(sha)
262
+ for method in ("suspect", "deploy"):
263
+ ids = [i for i, t in truth.items() if t["method"] == method]
264
+ if not ids or (method == "suspect" and mode != "off"):
265
+ continue # suspect links don't depend on the deploy mode
266
+ hits = sum(truth[i]["culprit"] in blamed.get(i, set()) for i in ids)
267
+ sizes = [len(blamed.get(i, ())) for i in ids]
268
+ key = method if method == "suspect" else f"files_{mode}"
269
+ out[key] = {"incidents": len(ids), "culprit_found": hits / len(ids),
270
+ "avg_commits_blamed": sum(sizes) / len(sizes)}
271
+ con.close()
272
+ return out
273
+
274
+
275
+ def validate(repo, stratum="stratum", boot=200, seed=0):
276
+ """Compare the labeler with the oracle and re-estimate on oracle labels."""
277
+ from .estimate import estimate, load_rows
278
+
279
+ oracle = json.loads((Path(repo) / ".ledger" / "oracle.json").read_text())
280
+ con = connect(Path(repo) / ".ledger" / "ledger.db")
281
+ tp = fp = fn = tn = 0
282
+ for r in con.execute("SELECT decision_id, bad FROM outcomes WHERE mature=1 AND kind!='no_commit'"):
283
+ truth = oracle.get(r["decision_id"], 0)
284
+ if r["bad"] and truth:
285
+ tp += 1
286
+ elif r["bad"]:
287
+ fp += 1
288
+ elif truth:
289
+ fn += 1
290
+ else:
291
+ tn += 1
292
+ # Same rows, but Y replaced by the oracle: isolates estimator error from label noise.
293
+ rows = load_rows(con, stratum)
294
+ con.close()
295
+ for row in rows:
296
+ row["y"] = oracle.get(row["id"], 0)
297
+ oracle_est = estimate(rows, boot=boot, seed=seed)
298
+ result = {
299
+ "incident_linking": incident_accuracy(repo),
300
+ "tp": tp, "fp": fp, "fn": fn, "tn": tn,
301
+ "precision": tp / (tp + fp) if tp + fp else None,
302
+ "recall": tp / (tp + fn) if tp + fn else None,
303
+ "oracle_dr": {a: e["estimates"]["dr"] for a, e in oracle_est["arms"].items()},
304
+ }
305
+ (Path(repo) / ".ledger" / "validation.json").write_text(json.dumps(result, indent=2))
306
+ return result