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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: consequence-ledger
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Measure what AI coding decisions actually cause: randomized routing, delayed outcome labels, causal estimates.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/sujan-mitta/consequence-ledger
|
|
7
|
+
Project-URL: Source, https://github.com/sujan-mitta/consequence-ledger
|
|
8
|
+
Project-URL: Changelog, https://github.com/sujan-mitta/consequence-ledger/blob/main/CHANGELOG.md
|
|
9
|
+
Keywords: ai,coding-agents,causal-inference,off-policy-evaluation,szz,git,software-engineering
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# Consequence Ledger
|
|
29
|
+
|
|
30
|
+
**Which AI coding tool actually *causes* fewer reverted and bug-fixed changes on your code?**
|
|
31
|
+
|
|
32
|
+
AI tools are judged on instant signals: a suggestion accepted, tests passing, a developer saying it felt faster. What matters arrives weeks later: the change gets reverted, or someone ships a fix for it. Comparing tools on those later outcomes is misleading unless you account for *which tasks each tool was given*. This prototype records that properly and turns it into cause-and-effect estimates.
|
|
33
|
+
|
|
34
|
+
It is the 90-day MVP from the "AI elephant in the room" research: decision logging with propensities, commit attribution, revert and SZZ outcome labels, and IPW / doubly-robust estimates with confidence intervals. It is pure Python standard library, with no dependencies beyond `git`.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
Python 3.9+ and git 2.28+. There are no dependencies.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/sujan-mitta/consequence-ledger.git
|
|
42
|
+
cd consequence-ledger
|
|
43
|
+
pip install . # gives you the `ledger` command; or skip this and use `python -m ledger` from the folder
|
|
44
|
+
ledger --version
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python -m ledger demo
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
This builds a synthetic repository with about 3,700 commits and a known true effect for each agent, then runs the whole pipeline and writes an HTML report. You'll see the naive comparison blame the wrong agent and the causal estimate recover the truth. A copy of the output is in [examples/demo-report.html](examples/demo-report.html).
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m ledger audit --repo path/to/repo --html audit.html
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
This gives a day-one baseline from git history alone: revert and fix rates per tool, detected from commit trailers and bot authors (Claude Code, Copilot, Cursor, Devin, Jules, Codex, Aider, OpenHands). It is **correlational**, and the output says so. See [examples/manifold-audit.html](examples/manifold-audit.html) for a run on a real open-source repo.
|
|
60
|
+
|
|
61
|
+
## Using it on a real repo
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
python -m ledger init # create .ledger/ and install the commit hook
|
|
65
|
+
python -m ledger decide --arms claude-code,codex,cursor \
|
|
66
|
+
--scores 1.0,0.6,0.4 --epsilon 0.4 \
|
|
67
|
+
--context '{"stratum": "bugfix"}' # prints which tool to use for this task
|
|
68
|
+
# ... do the task with that tool; commits on this branch are tagged "Ledger-Decision-Id: <id>" automatically
|
|
69
|
+
python -m ledger done # stop tagging on this branch (--all: every branch)
|
|
70
|
+
python -m ledger attach --decision <id> --commit <sha> # if a squash merge dropped the trailer
|
|
71
|
+
|
|
72
|
+
# optional: production incidents and deploys exported from Sentry, PagerDuty, incident.io …
|
|
73
|
+
python -m ledger import incidents --file incidents.csv # id, started_at, title, commits, files
|
|
74
|
+
python -m ledger import deploys --file deploys.csv # sha, deployed_at
|
|
75
|
+
|
|
76
|
+
# or pull incidents live (read-only key in an environment variable, never an argument)
|
|
77
|
+
python -m ledger sync sentry --org your-org-slug --days 90 # needs SENTRY_AUTH_TOKEN
|
|
78
|
+
python -m ledger sync pagerduty --days 90 # needs PAGERDUTY_API_KEY
|
|
79
|
+
|
|
80
|
+
# before you start, and any time you wonder if something is stuck
|
|
81
|
+
python -m ledger plan --base-rate 0.12 --effect -0.03 # how many finished decisions you need
|
|
82
|
+
python -m ledger status # gaps that would mislead an estimate
|
|
83
|
+
|
|
84
|
+
# a week in: an early readout; weeks later: the real one
|
|
85
|
+
python -m ledger label --fix-style keywords # reverts, fixes and incidents within 30 days
|
|
86
|
+
python -m ledger estimate # causal estimates with 95% CIs
|
|
87
|
+
python -m ledger report # .ledger/report.html
|
|
88
|
+
|
|
89
|
+
# once you have a few hundred finished decisions
|
|
90
|
+
python -m ledger breakdown --stratum difficulty # which tool suits which kind of task
|
|
91
|
+
python -m ledger recommend --stratum difficulty # and what routing by that is honestly worth
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Command | What it does |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `init` | Creates `.ledger/` (self-ignored by git) and installs a `prepare-commit-msg` hook. It won't overwrite a hook you already have, and re-running it upgrades an older ledger hook. |
|
|
97
|
+
| `decide` | Chooses an arm at random with `p = (1-ε)·softmax(score/τ) + ε/k` and logs every arm's probability. The decision is active on the branch you are on, so several agents can work on several branches or git worktrees at once without sharing a decision. All worktrees and subfolders use one ledger at the repository root. |
|
|
98
|
+
| `sync sentry` / `sync pagerduty` | Pull error-level Sentry issues or high-urgency PagerDuty incidents as incidents. Keys come only from `SENTRY_AUTH_TOKEN` / `PAGERDUTY_API_KEY` and are never printed, even in errors. Neither API names the commit that caused an incident, so these carry timing only: link them with an export that names commits, or `--incident-linking`. Re-syncing never erases links from an earlier export. |
|
|
99
|
+
| `plan` | Simulates your routing and tells you how many finished decisions give 80% power, both for one look and for checking repeatedly. |
|
|
100
|
+
| `status` | Flags what would mislead an estimate: decisions with no commit, stale labels, near-zero routing chances, one-arm ledgers, incidents that can't be linked. |
|
|
101
|
+
| `import` | Loads incident or deploy exports (CSV or JSON, common field names accepted). |
|
|
102
|
+
| `label` | Maps commits to decisions and finds reverts, fixes (SZZ via `git blame`) and linked incidents. A decision counts only once its whole window has passed; a separate early label (`--early-days 7`) feeds the early readout. |
|
|
103
|
+
| `estimate` | Naive, IPW, self-normalised IPW, direct method and doubly robust estimates per arm. Bootstrap and influence-function CIs, effective sample size, minimum propensity, and a check that unmerged work is balanced across arms. `--cluster developer` resamples whole clusters when outcomes are correlated. |
|
|
104
|
+
| `breakdown` | Which tool is better for which kind of task (`--stratum difficulty`). Raw and shrunk effects per stratum with Bonferroni-adjusted p-values, so a lucky stratum is not mistaken for a pattern. |
|
|
105
|
+
| `recommend` | What routing each task type to its best tool would be worth. The tool is chosen on one half of your decisions and judged on the other, so the gain is not inflated; it also shows the gain from simply using the best tool. |
|
|
106
|
+
| `report` | A self-contained HTML report (light and dark). |
|
|
107
|
+
| `audit` | Per-tool baseline from history, with Wilson CIs and size-adjusted rates. |
|
|
108
|
+
| `demo` | Synthetic end-to-end run that checks labels against an oracle. |
|
|
109
|
+
|
|
110
|
+
Useful options:
|
|
111
|
+
- `--fix-style keywords` for repos that write "Fix crash in …" rather than `fix: …`
|
|
112
|
+
- `--fix-regex` for anything else
|
|
113
|
+
- `--ignore-revs .git-blame-ignore-revs` to see through formatting commits
|
|
114
|
+
- `--horizon 30`, `--as-of 2026-06-30`, `--baseline <arm>`, `--clip 20`
|
|
115
|
+
- `--cluster developer` (any context key) for cluster-robust intervals
|
|
116
|
+
- `--stratum difficulty,language` to model outcomes per combination of context keys
|
|
117
|
+
- `--incident-linking latest|window` to also link incidents that name no suspect commit (off by default; see below)
|
|
118
|
+
- `--planned-n 4000` sets the sample size the anytime-valid interval is tuned for. Choose it before you start.
|
|
119
|
+
- `--folds 5` cross-fits the outcome model (0 turns it off). It matters when strata are many and small: in
|
|
120
|
+
simulation the interval covered the truth 87.7% of the time without it and 95.3% with it.
|
|
121
|
+
|
|
122
|
+
**Checking every week?** Ordinary 95% intervals don't hold up to repeated looks: in simulation, 16 weekly checks under a true null "found" a winner 30% of the time. Each verdict therefore also says whether it survives the anytime-valid confidence sequence (Waudby-Smith et al., *Annals of Statistics* 2024), which kept that rate at 3%.
|
|
123
|
+
|
|
124
|
+
The report warns when:
|
|
125
|
+
- one arm's work goes unmerged more often (a chi-square test), since that biases the merged-only comparison
|
|
126
|
+
- an arm had a near-zero chance of being picked for some tasks
|
|
127
|
+
- an arm's effective sample size is small
|
|
128
|
+
|
|
129
|
+
## How it decides "bad"
|
|
130
|
+
|
|
131
|
+
A change is bad if, within the horizon after its first commit:
|
|
132
|
+
- it is **reverted**: git's `This reverts commit <sha>`, or a squash-merged GitHub `Revert "<subject>"` PR, or
|
|
133
|
+
- a **fix commit** changes lines it introduced (SZZ), or names its PR ("Bug introduced in #1507", "regression from #88"), or
|
|
134
|
+
- a **production incident** names it as a suspect commit.
|
|
135
|
+
|
|
136
|
+
Incidents that name only files are left unlinked by default. In testing, blaming "the latest deploy that touched those files" found the true culprit only 14% of the time, because files change often. A missed incident can only shrink a difference between tools; a wrongly blamed one adds noise. Export suspect commits where your tooling has them (Sentry does).
|
|
137
|
+
|
|
138
|
+
**Early readout.** `estimate` also reports differences on a 7-day label, which includes recent decisions the main estimate must exclude. Next to it, the report shows how 7-day and 30-day differences compared on decisions old enough to have both. In the demo only 20% of bad outcomes surfaced within a week, and one tool's early difference pointed the wrong way. Treat the early readout as a hint until that comparison says otherwise.
|
|
139
|
+
|
|
140
|
+
The SZZ step includes the standard refinements against false positives. Each one mattered on real data:
|
|
141
|
+
- ignore whitespace-only edits (`-w`), and blank or comment-only lines
|
|
142
|
+
- ignore lines that were moved rather than changed
|
|
143
|
+
- skip tests, docs, editor and CI config, and lockfiles
|
|
144
|
+
- never treat a merge commit as a fix, even one titled "Merge pull request … from me/fix-crash"
|
|
145
|
+
- ignore a fix that blames more than 10 commits (`--max-blamed`, 0 to keep all): the research found such fixes are outliers, and cutting them raised precision
|
|
146
|
+
- optionally look through listed formatting commits
|
|
147
|
+
|
|
148
|
+
**Squash merges.** GitHub's squash merge drops the `Ledger-Decision-Id` trailer, which would make merged work look abandoned. `label` recovers these automatically: it fingerprints the branch's combined diff (`git patch-id --stable`) and looks for a commit on your branch with the same fingerprint. It only accepts a unique match that is newer than the branch's first commit, never guesses, and reports `squash_recovered`. It needs the branch's commits to still exist locally (a branch, a remote-tracking ref, or the reflog); `ledger attach` covers anything else.
|
|
149
|
+
|
|
150
|
+
Fix analysis is remembered in the ledger, so re-running `label` is near-instant: the same repository went from 46 seconds to under a tenth of a second.
|
|
151
|
+
|
|
152
|
+
## What we learned
|
|
153
|
+
|
|
154
|
+
Full details are in [research/FINDINGS.md](research/FINDINGS.md):
|
|
155
|
+
- Doubly-robust estimates are unbiased (< 0.1 pp) with 93.5–95% bootstrap CI coverage. Naive comparisons were off by up to 7 pp and picked the wrong winner.
|
|
156
|
+
- Re-checking weekly with ordinary CIs gives a **30% false-positive rate**. The anytime-valid sequence gives 3%.
|
|
157
|
+
- How much data you need depends on your routing. A 3 pp difference needed about 8,000 mature decisions when routing was lopsided (the demo, ε = 0.1) but about 4,000 with near 50/50 routing, or about 8,000 if you also want it safe to check repeatedly. Run `ledger plan` with your own numbers.
|
|
158
|
+
- Label noise inflates levels but mostly leaves differences between arms alone. Missed bugs only shrink effects. Report differences.
|
|
159
|
+
- On elalish/manifold, Claude Code commits looked worse raw (11.3% vs 7.5%) and about the same size-adjusted (7.1% vs 8.1%). The CIs overlap, which is the case for randomizing.
|
|
160
|
+
|
|
161
|
+
## Documentation
|
|
162
|
+
|
|
163
|
+
- [docs/METHODOLOGY.md](docs/METHODOLOGY.md): exactly what is computed, the assumptions it needs, and what it cannot tell you
|
|
164
|
+
- [research/FINDINGS.md](research/FINDINGS.md): simulation studies and real-repository results
|
|
165
|
+
- [CHANGELOG.md](CHANGELOG.md), [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md)
|
|
166
|
+
|
|
167
|
+
## Layout
|
|
168
|
+
|
|
169
|
+
```
|
|
170
|
+
ledger/ the package: policy (routing), label + squash (outcomes), estimate, audit, plan, status,
|
|
171
|
+
incidents + sentry + pagerduty, report, demo, db, gitio, cli
|
|
172
|
+
tests/ 230 unittest cases: real git repos in temp dirs, local fake servers, estimator Monte Carlo checks
|
|
173
|
+
tools/ coverage_lite.py, dependency-free line coverage
|
|
174
|
+
docs/ METHODOLOGY.md
|
|
175
|
+
research/ simulate.py (coverage, power, noise, design, peeking studies), results/, FINDINGS.md
|
|
176
|
+
examples/ generated demo report and the manifold audit
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Run the tests:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
python -m unittest discover -s tests
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## Limits (honest list)
|
|
186
|
+
|
|
187
|
+
- Sentry and PagerDuty can be synced live, but the syncs were only tested against fake servers and (for Sentry) an empty account; others need exports. Customer impact (tickets, churn) isn't linked.
|
|
188
|
+
- The outcome model for DR is a per-stratum mean. Many context keys at once need a real model with cross-fitting.
|
|
189
|
+
- The early readout is a plain shorter window, not a calibrated surrogate index.
|
|
190
|
+
- Audit mode is observational. It describes; it does not prove.
|
|
191
|
+
- Python 3.9+ and git 2.28+.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
consequence_ledger-0.3.0.dist-info/licenses/LICENSE,sha256=1lhJHdAJxo3B2z9l2KvKj6DLRXalmiELd5S_MrlW7eI,1068
|
|
2
|
+
ledger/__init__.py,sha256=IYhnUw-2ec9okTh6GjUpkWAtLikQOHVDK3bAHaJMIZY,97
|
|
3
|
+
ledger/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
4
|
+
ledger/audit.py,sha256=QBQQzyTL6cqZIWVd2Wt4QvTf4ohn-rbG2J4SBmygwyI,8594
|
|
5
|
+
ledger/cli.py,sha256=P8Orum30XdXOh5xH7SvXqrEgWBX07qYU1PKnV1LmVrM,32543
|
|
6
|
+
ledger/db.py,sha256=ddY_bfHUnXI9Vd0t8nd5kcuorHCRYv7gmLs1awinwSU,2874
|
|
7
|
+
ledger/demo.py,sha256=9doGAgvEdnA2TRy-4CF42ZsQLtbNGHGIoqma4_NFdBA,13663
|
|
8
|
+
ledger/estimate.py,sha256=hOOxmkrsvYHxamGHn3tq9wpwHJNhU5weGVvpXKHcaJo,17809
|
|
9
|
+
ledger/gitio.py,sha256=ix5jWWSbBAGHR0GT_QoH4jehPCfrl2VtlSwx7zkMkow,3946
|
|
10
|
+
ledger/incidents.py,sha256=iDZR450x9v-8S020zT2u7aG5SQ7Q4z4KdOTfdXy7vg4,8433
|
|
11
|
+
ledger/label.py,sha256=b9rXveX2LsQOpvl5K3-iYQwhHENzT6SMKzrabXZmr8E,18135
|
|
12
|
+
ledger/pagerduty.py,sha256=K7LLuhN_aUxn3jV0bdFbj0k9OYnEjTYk3YdYiuRZ1eQ,4294
|
|
13
|
+
ledger/plan.py,sha256=Y-DgVJ5XL9IBmClRJsNomG6K7u_xpQNVO7F3WV0oCd0,2922
|
|
14
|
+
ledger/policy.py,sha256=f8HvrS3KJgIfPhdifD6WHpmLE3qyrPyw1f4uVKyz_5k,2109
|
|
15
|
+
ledger/report.py,sha256=EBph-J7oKU6ZdMOCuURheMw434K35h99_Pif3Px9aFs,20938
|
|
16
|
+
ledger/segments.py,sha256=wGr98sP5vBSL_LJrMQphV8LZTnYeRc-Pkhn5-RQFQHk,6766
|
|
17
|
+
ledger/sentry.py,sha256=UGWldoC2fTgAqqXxK8e4ag6N3aIg_YhnZrweMCHYvQU,3884
|
|
18
|
+
ledger/squash.py,sha256=kzKkNHHXgtbwslQ8BGdh01ypGtcMctZCmdQNf7IYNkE,4231
|
|
19
|
+
ledger/status.py,sha256=QwduB9ekijadNTygPIuozj88dvBnhfpofMl3It-2S6c,3352
|
|
20
|
+
consequence_ledger-0.3.0.dist-info/METADATA,sha256=vBU7whqfE6-jekCjSjHjkKL2suzCFM747nYLvgmdZT0,14236
|
|
21
|
+
consequence_ledger-0.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
22
|
+
consequence_ledger-0.3.0.dist-info/entry_points.txt,sha256=MMH2iMSp-kYEpL7AAmFfUgDn4iRIgkF-Pd04uPvAFOw,43
|
|
23
|
+
consequence_ledger-0.3.0.dist-info/top_level.txt,sha256=m7uk8iJxEERz_eg6MUtQ-DR9EFBsRR3PmqxMKlSXm_I,7
|
|
24
|
+
consequence_ledger-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sujan-mitta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ledger
|
ledger/__init__.py
ADDED
ledger/__main__.py
ADDED
ledger/audit.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Baseline audit: how often each tool's commits get reverted or fixed, from history alone.
|
|
2
|
+
|
|
3
|
+
No randomisation, so this is correlational: tools are used on different kinds
|
|
4
|
+
of work. It is what a team can learn on day one, before `decide` has logged
|
|
5
|
+
any propensities, and it shows which comparisons are worth randomising.
|
|
6
|
+
Size-adjusted rates (direct standardisation over commit-size bands) remove
|
|
7
|
+
one obvious confounder; they cannot remove the rest.
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from collections import defaultdict
|
|
13
|
+
|
|
14
|
+
from .gitio import GitError, git, lines_changed, log_commits
|
|
15
|
+
from .label import DAY, DEFAULT_MAX_BLAMED, FIX_RE, first_bad, is_fix, outcome_events
|
|
16
|
+
|
|
17
|
+
TRAILER_RE = re.compile(r"(?im)^co-authored-by:\s*(.+)$")
|
|
18
|
+
AUTHOR_EMAIL_RE = re.compile(r"<([^>]*)>\s*$")
|
|
19
|
+
|
|
20
|
+
# (tool, scope, pattern). Trailer patterns run on each Co-authored-by value on its own, so a commit
|
|
21
|
+
# that merely *mentions* Claude in its body is not an AI commit. Email patterns use bot-specific
|
|
22
|
+
# addresses so a human called Devin is not misclassified; GitHub bot addresses carry an "<id>+" prefix.
|
|
23
|
+
# First match wins.
|
|
24
|
+
RULES = [
|
|
25
|
+
("claude-code", "message", r"Generated with \[?Claude Code"),
|
|
26
|
+
("claude-code", "trailer", r"\bclaude\b|@anthropic\.com"),
|
|
27
|
+
("devin", "email", r"^(\d+\+)?devin-ai-integration(\[bot\])?@"),
|
|
28
|
+
("devin", "trailer", r"\bdevin-ai-integration\b|@devin\.ai\b"),
|
|
29
|
+
("jules", "email", r"^(\d+\+)?google-labs-jules(\[bot\])?@"),
|
|
30
|
+
("copilot", "email", r"^(\d+\+)?copilot@users\.noreply\.github\.com$|^(\d+\+)?copilot-swe-agent(\[bot\])?@"),
|
|
31
|
+
("copilot", "trailer", r"\bcopilot\b"),
|
|
32
|
+
("cursor", "email", r"^cursoragent@cursor\.com$"),
|
|
33
|
+
("cursor", "trailer", r"@cursor\.(sh|com)\b|\bcursor( agent)?\b(?!-)"),
|
|
34
|
+
("codex", "email", r"chatgpt-codex-connector"),
|
|
35
|
+
("codex", "trailer", r"\bcodex\b|@openai\.com\b"),
|
|
36
|
+
("gemini", "email", r"gemini-code-assist"),
|
|
37
|
+
("gemini", "trailer", r"^gemini\b[^<]*<[^>]*(google|gemini)"),
|
|
38
|
+
("aider", "trailer", r"\baider\b"),
|
|
39
|
+
("aider", "author", r"\(aider\)\s*<"),
|
|
40
|
+
("openhands", "email", r"^openhands@all-hands\.dev$"),
|
|
41
|
+
("openhands", "trailer", r"\bopenhands\b"),
|
|
42
|
+
]
|
|
43
|
+
# Files that show a tool is set up in a repository even when its commits carry no signature. A 2026 census of
|
|
44
|
+
# 180 million repositories found author-bot lookups recover only 3% of Claude Code commits, and only 64% of
|
|
45
|
+
# repositories with Claude config files also had a signed commit, so the audit would otherwise undercount.
|
|
46
|
+
CONFIG_FILES = [
|
|
47
|
+
("claude-code", r"^CLAUDE\.md$|^\.claude/"),
|
|
48
|
+
("gemini", r"^GEMINI\.md$"),
|
|
49
|
+
("cursor", r"^\.cursorrules$|^\.cursor/"),
|
|
50
|
+
("copilot", r"^\.github/copilot-instructions\.md$"),
|
|
51
|
+
("aider", r"^\.aider\.conf\.yml$"),
|
|
52
|
+
("windsurf", r"^\.windsurfrules$"),
|
|
53
|
+
("devin", r"^\.devin\.yaml$"),
|
|
54
|
+
("junie", r"^\.junie/"),
|
|
55
|
+
("openhands", r"^\.openhands/"),
|
|
56
|
+
("agents.md", r"^AGENTS\.md$"), # read by Codex and several other tools, so it names no single one
|
|
57
|
+
]
|
|
58
|
+
SIZE_BANDS = [(0, 20, "1-20"), (21, 200, "21-200"), (201, float("inf"), "200+")]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def compile_rules(extra=()):
|
|
62
|
+
"""extra: ["tool=regex", ...] matched against the whole message, checked before the built-ins."""
|
|
63
|
+
rules = []
|
|
64
|
+
for spec in extra:
|
|
65
|
+
tool, _, pattern = spec.partition("=")
|
|
66
|
+
if not tool or not pattern:
|
|
67
|
+
raise ValueError(f"rule must look like tool=regex, got {spec!r}")
|
|
68
|
+
rules.append((tool.strip(), "message", re.compile(pattern, re.I | re.M)))
|
|
69
|
+
rules += [(t, s, re.compile(p, re.I | re.M)) for t, s, p in RULES]
|
|
70
|
+
return rules
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def classify(commit, rules):
|
|
74
|
+
trailers = [v.strip() for v in TRAILER_RE.findall(commit["body"])]
|
|
75
|
+
m = AUTHOR_EMAIL_RE.search(commit.get("author", ""))
|
|
76
|
+
email = m.group(1) if m else ""
|
|
77
|
+
for tool, scope, rx in rules:
|
|
78
|
+
if scope == "message" and rx.search(commit["body"]):
|
|
79
|
+
return tool
|
|
80
|
+
if scope == "trailer" and any(rx.search(t) for t in trailers):
|
|
81
|
+
return tool
|
|
82
|
+
if scope == "email" and rx.search(email):
|
|
83
|
+
return tool
|
|
84
|
+
if scope == "author" and rx.search(commit.get("author", "")):
|
|
85
|
+
return tool
|
|
86
|
+
return "human"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def wilson(k, n, z=1.959964):
|
|
90
|
+
if n == 0:
|
|
91
|
+
return float("nan"), float("nan")
|
|
92
|
+
p = k / n
|
|
93
|
+
centre = (p + z * z / (2 * n)) / (1 + z * z / n)
|
|
94
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
|
|
95
|
+
return max(0.0, centre - half), min(1.0, centre + half)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def band(size):
|
|
99
|
+
return next(name for lo, hi, name in SIZE_BANDS if lo <= size <= hi)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def detect_adoption(repo, rev="HEAD"):
|
|
103
|
+
"""{tool: [config files]} present in the tree at rev; empty if the tree cannot be listed."""
|
|
104
|
+
try:
|
|
105
|
+
names = git(repo, "ls-tree", "-r", "--name-only", rev).splitlines()
|
|
106
|
+
except GitError:
|
|
107
|
+
return {}
|
|
108
|
+
found = {}
|
|
109
|
+
for tool, pattern in CONFIG_FILES:
|
|
110
|
+
rx = re.compile(pattern)
|
|
111
|
+
hits = [n for n in names if rx.search(n)]
|
|
112
|
+
if hits:
|
|
113
|
+
found[tool] = hits[:5]
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def detection_notes(adoption, tools, mature):
|
|
118
|
+
"""Plain-language warnings where config files say a tool is in use but its commits are barely, or not, detected."""
|
|
119
|
+
notes = []
|
|
120
|
+
for tool, files in adoption.items():
|
|
121
|
+
if tool == "agents.md":
|
|
122
|
+
continue
|
|
123
|
+
tagged = tools.get(tool, {}).get("commits", 0)
|
|
124
|
+
if tagged == 0 or (mature and tagged / mature < 0.01):
|
|
125
|
+
what = "no commit" if tagged == 0 else f"only {tagged} of {mature} commits"
|
|
126
|
+
notes.append(f"{files[0]} is in the repository but {what} is signed as {tool}. Its commit signature may "
|
|
127
|
+
f"be switched off, so some of its work is probably counted under 'human'. Compare with "
|
|
128
|
+
f"`--rule '{tool}=<regex>'` if you know another marker.")
|
|
129
|
+
return notes
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def audit(repo, horizon_days=30, as_of=None, since=None, rev="HEAD", rules=None, ignore_revs=(), fix_re=FIX_RE,
|
|
133
|
+
max_blamed=DEFAULT_MAX_BLAMED):
|
|
134
|
+
as_of = int(as_of if as_of is not None else time.time())
|
|
135
|
+
rules = rules or compile_rules()
|
|
136
|
+
commits = log_commits(repo, rev)
|
|
137
|
+
sizes = lines_changed(repo, rev)
|
|
138
|
+
horizon = horizon_days * DAY
|
|
139
|
+
|
|
140
|
+
targets = {}
|
|
141
|
+
for c in commits:
|
|
142
|
+
if len(c["parents"]) > 1 or c["sha"] not in sizes or sizes[c["sha"]] == 0:
|
|
143
|
+
continue # merges and empty commits
|
|
144
|
+
if since and c["ts"] < since:
|
|
145
|
+
continue
|
|
146
|
+
if c["ts"] + horizon > as_of:
|
|
147
|
+
continue # immature: its window hasn't closed
|
|
148
|
+
targets[c["sha"]] = classify(c, rules)
|
|
149
|
+
by_sha = {c["sha"]: c for c in commits}
|
|
150
|
+
szz_stats = {}
|
|
151
|
+
events = outcome_events(repo, commits, set(targets), ignore_revs, fix_re, stats=szz_stats, max_blamed=max_blamed)
|
|
152
|
+
|
|
153
|
+
groups = defaultdict(lambda: {"n": 0, "revert": 0, "fix": 0, "bands": defaultdict(lambda: [0, 0])})
|
|
154
|
+
band_totals = defaultdict(int)
|
|
155
|
+
for sha, tool in targets.items():
|
|
156
|
+
g = groups[tool]
|
|
157
|
+
hit = first_bad(events, [sha], by_sha[sha]["ts"], horizon)
|
|
158
|
+
b = band(sizes[sha])
|
|
159
|
+
g["n"] += 1
|
|
160
|
+
g["bands"][b][1] += 1
|
|
161
|
+
band_totals[b] += 1
|
|
162
|
+
if hit:
|
|
163
|
+
g[hit[1]] += 1
|
|
164
|
+
g["bands"][b][0] += 1
|
|
165
|
+
|
|
166
|
+
total = sum(band_totals.values())
|
|
167
|
+
tools = {}
|
|
168
|
+
for tool, g in sorted(groups.items(), key=lambda kv: -kv[1]["n"]):
|
|
169
|
+
bad = g["revert"] + g["fix"]
|
|
170
|
+
lo, hi = wilson(bad, g["n"])
|
|
171
|
+
# Direct standardisation: this tool's per-band rates applied to the repo-wide size mix.
|
|
172
|
+
covered = [b for b in band_totals if g["bands"][b][1] > 0]
|
|
173
|
+
weight = sum(band_totals[b] for b in covered)
|
|
174
|
+
adjusted = (sum(band_totals[b] * g["bands"][b][0] / g["bands"][b][1] for b in covered) / weight
|
|
175
|
+
if weight else float("nan"))
|
|
176
|
+
tools[tool] = {
|
|
177
|
+
"commits": g["n"], "reverted": g["revert"], "fixed": g["fix"],
|
|
178
|
+
"bad_rate": bad / g["n"], "ci_lo": lo, "ci_hi": hi,
|
|
179
|
+
"size_adjusted_rate": adjusted,
|
|
180
|
+
"size_bands": {b: {"bad": v[0], "n": v[1]} for b, v in sorted(g["bands"].items())},
|
|
181
|
+
"band_coverage": weight / total if total else 0.0,
|
|
182
|
+
}
|
|
183
|
+
adoption = detect_adoption(repo, rev)
|
|
184
|
+
return {"horizon_days": horizon_days, "as_of": as_of, "since": since, "commits_scanned": len(commits),
|
|
185
|
+
"mature_commits": len(targets), "fix_commits": sum(is_fix(c, fix_re) for c in commits),
|
|
186
|
+
"fixes_too_diffuse": szz_stats.get("fixes_too_diffuse", 0), "size_mix": dict(band_totals), "tools": tools,
|
|
187
|
+
"adoption": adoption, "detection_notes": detection_notes(adoption, tools, len(targets))}
|