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