coauthored 0.1.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.
- coauthored/__init__.py +3 -0
- coauthored/analysis/__init__.py +0 -0
- coauthored/analysis/aggregate.py +154 -0
- coauthored/analysis/figures.py +375 -0
- coauthored/analysis/glmm.py +156 -0
- coauthored/analysis/hypotheses.py +440 -0
- coauthored/analysis/run.py +510 -0
- coauthored/bots.py +46 -0
- coauthored/cli.py +241 -0
- coauthored/configs/bots.yaml +32 -0
- coauthored/configs/signatures.yaml +482 -0
- coauthored/dashboard.py +176 -0
- coauthored/guard.py +29 -0
- coauthored/match.py +145 -0
- coauthored/paper.py +313 -0
- coauthored/policy/__init__.py +0 -0
- coauthored/policy/classify.py +85 -0
- coauthored/scan/__init__.py +0 -0
- coauthored/scan/gitlog.py +86 -0
- coauthored/scan/identity.py +34 -0
- coauthored/scan/records.py +54 -0
- coauthored/scan/run.py +77 -0
- coauthored/signatures/__init__.py +0 -0
- coauthored/signatures/catalogue.py +90 -0
- coauthored/signatures/matcher.py +93 -0
- coauthored/survival/__init__.py +0 -0
- coauthored/survival/classify.py +68 -0
- coauthored-0.1.0.dist-info/METADATA +124 -0
- coauthored-0.1.0.dist-info/RECORD +32 -0
- coauthored-0.1.0.dist-info/WHEEL +4 -0
- coauthored-0.1.0.dist-info/entry_points.txt +2 -0
- coauthored-0.1.0.dist-info/licenses/LICENSE +202 -0
coauthored/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Per-commit records → repository-month aggregates (MISSION §4.2). The only shape that leaves the machine.
|
|
2
|
+
|
|
3
|
+
Primary specification (MISSION §3.1–3.3, DECISIONS D7): non-merge commits, author not a maintenance bot,
|
|
4
|
+
month of the committer date in UTC, lenient attribution. Every sensitivity analysis is a keyword argument.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
|
|
14
|
+
from coauthored.scan.run import read_records
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Spec:
|
|
19
|
+
name: str = "primary"
|
|
20
|
+
date: str = "committer_date" # or "author_date"
|
|
21
|
+
merges: bool = False # include merge commits
|
|
22
|
+
first_parent_only: bool = False
|
|
23
|
+
strict: bool = False # strict trailer position for co-author signatures
|
|
24
|
+
drop_maint_bots: bool = True
|
|
25
|
+
start: str = "2023-01"
|
|
26
|
+
end: str = "2026-09"
|
|
27
|
+
exclude_signatures: tuple[str, ...] = () # catalogue ids that do not count (post-hoc analyses only)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SENSITIVITY = [
|
|
31
|
+
Spec(),
|
|
32
|
+
Spec(name="author_date", date="author_date"),
|
|
33
|
+
Spec(name="merges_included", merges=True),
|
|
34
|
+
Spec(name="first_parent", first_parent_only=True),
|
|
35
|
+
Spec(name="strict_trailers", strict=True),
|
|
36
|
+
Spec(name="bots_included", drop_maint_bots=False),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Not pre-registered (DEVIATIONS 11): applied review suggestions, where a person clicks "commit suggestion"
|
|
40
|
+
# on a review bot's comment and GitHub adds the bot as co-author, are not a coding agent writing the change.
|
|
41
|
+
REVIEW_SUGGESTIONS = ("copilot-coauthor", "gemini-code-assist-coauthor")
|
|
42
|
+
POST_HOC = [Spec(name="no_review_suggestions", exclude_signatures=REVIEW_SUGGESTIONS)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_commits(path: Path, repo: str) -> pl.DataFrame:
|
|
46
|
+
rows = list(read_records(path))
|
|
47
|
+
if not rows:
|
|
48
|
+
return pl.DataFrame()
|
|
49
|
+
df = pl.DataFrame(rows, infer_schema_length=None)
|
|
50
|
+
if "bare_assisted" not in df.columns: # records written before the indicator existed
|
|
51
|
+
df = df.with_columns(pl.lit(False).alias("bare_assisted"))
|
|
52
|
+
return df.with_columns(pl.lit(repo).alias("repo"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def mark_shared(frames: list[pl.DataFrame]) -> list[pl.DataFrame]:
|
|
56
|
+
"""Flag commits already seen in an earlier repository of the list (PREREGISTRATION §3, shared history).
|
|
57
|
+
|
|
58
|
+
``frames`` must be ordered by repository creation date; the earliest repository keeps the commit.
|
|
59
|
+
"""
|
|
60
|
+
seen: set[str] = set()
|
|
61
|
+
out = []
|
|
62
|
+
for df in frames:
|
|
63
|
+
shas = df["sha"].to_list()
|
|
64
|
+
out.append(df.with_columns(pl.Series("shared", [sha in seen for sha in shas])))
|
|
65
|
+
seen.update(shas)
|
|
66
|
+
return out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def reverts(df: pl.DataFrame, spec: Spec, reverted_sha: pl.Series | None = None) -> pl.DataFrame:
|
|
70
|
+
"""Per repository, month and attribution status: window commits and how many were later reverted (RQ6).
|
|
71
|
+
|
|
72
|
+
By month so that attributed and unattributed commits can be compared at equal exposure time.
|
|
73
|
+
``reverted_sha``: the revert targets to look up (default: those in ``df``)."""
|
|
74
|
+
targets = df["reverted_sha"] if reverted_sha is None else reverted_sha
|
|
75
|
+
reverted = {r for r in targets.to_list() if r}
|
|
76
|
+
prefixes = {r[:7] for r in reverted}
|
|
77
|
+
c = filter_commits(df, spec).with_columns(
|
|
78
|
+
pl.col("sha")
|
|
79
|
+
.map_elements(
|
|
80
|
+
lambda sha: sha[:7] in prefixes and any(sha.startswith(r) for r in reverted),
|
|
81
|
+
return_dtype=pl.Boolean,
|
|
82
|
+
)
|
|
83
|
+
.alias("was_reverted")
|
|
84
|
+
)
|
|
85
|
+
return (
|
|
86
|
+
c.group_by("repo", "month", "attr")
|
|
87
|
+
.agg(pl.len().alias("n"), pl.col("was_reverted").sum().alias("n_reverted"))
|
|
88
|
+
.with_columns(pl.lit(spec.name).alias("spec"))
|
|
89
|
+
.sort("repo", "month", "attr")
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def month_utc(column: str) -> pl.Expr:
|
|
94
|
+
"""UTC calendar month of an ISO-8601 git date."""
|
|
95
|
+
return (
|
|
96
|
+
pl.col(column)
|
|
97
|
+
# newer git writes a UTC offset as "Z" in --date=iso-strict output, older git as "+00:00"
|
|
98
|
+
.str.replace(r"Z$", "+00:00")
|
|
99
|
+
.str.to_datetime("%Y-%m-%dT%H:%M:%S%:z", time_zone="UTC")
|
|
100
|
+
.dt.strftime("%Y-%m")
|
|
101
|
+
.alias("month")
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def filter_commits(df: pl.DataFrame, spec: Spec) -> pl.DataFrame:
|
|
106
|
+
out = df.with_columns(month_utc(spec.date))
|
|
107
|
+
if not spec.merges:
|
|
108
|
+
out = out.filter(pl.col("n_parents") <= 1)
|
|
109
|
+
if spec.first_parent_only:
|
|
110
|
+
out = out.filter(pl.col("first_parent"))
|
|
111
|
+
if spec.drop_maint_bots:
|
|
112
|
+
out = out.filter(~pl.col("author_maint_bot"))
|
|
113
|
+
flag = pl.col("attributed_strict" if spec.strict else "attributed")
|
|
114
|
+
if spec.exclude_signatures:
|
|
115
|
+
ids = pl.col("signature_ids").cast(pl.List(pl.String)) # list[null] in repositories with no signature
|
|
116
|
+
others = ids.list.set_difference(list(spec.exclude_signatures)).list.len() > 0
|
|
117
|
+
flag = flag & others
|
|
118
|
+
return out.filter(pl.col("month").is_between(pl.lit(spec.start), pl.lit(spec.end))).with_columns(
|
|
119
|
+
flag.alias("attr"),
|
|
120
|
+
pl.when(flag).then(pl.col("tool")).otherwise(None).alias("attr_tool"),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def repo_month(df: pl.DataFrame, spec: Spec) -> pl.DataFrame:
|
|
125
|
+
"""One row per repo-month: commits, attributed commits, active and attributing authors, mirror share."""
|
|
126
|
+
c = filter_commits(df, spec)
|
|
127
|
+
return (
|
|
128
|
+
c.group_by("repo", "month")
|
|
129
|
+
.agg(
|
|
130
|
+
pl.len().alias("n"),
|
|
131
|
+
pl.col("attr").sum().alias("n_attr"),
|
|
132
|
+
pl.col("author_h").n_unique().alias("authors"),
|
|
133
|
+
pl.col("author_h").filter(pl.col("attr")).n_unique().alias("authors_attr"),
|
|
134
|
+
pl.col("mirror_export").sum().alias("n_mirror"),
|
|
135
|
+
pl.col("bare_assisted").sum().alias("n_bare_assisted"),
|
|
136
|
+
)
|
|
137
|
+
.sort("repo", "month")
|
|
138
|
+
.with_columns(pl.lit(spec.name).alias("spec"))
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def repo_month_tool(df: pl.DataFrame, spec: Spec) -> pl.DataFrame:
|
|
143
|
+
c = filter_commits(df, spec).filter(pl.col("attr"))
|
|
144
|
+
return (
|
|
145
|
+
c.group_by("repo", "month", "attr_tool")
|
|
146
|
+
.agg(pl.len().alias("n_attr"))
|
|
147
|
+
.rename({"attr_tool": "tool"})
|
|
148
|
+
.sort("repo", "month", "tool")
|
|
149
|
+
.with_columns(pl.lit(spec.name).alias("spec"))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def spec_dict(spec: Spec) -> dict[str, object]:
|
|
154
|
+
return asdict(spec)
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Paper figures F1–F6 and F8 (MISSION §7.4), house style shared with the author's other papers.
|
|
2
|
+
|
|
3
|
+
Libertinus Serif (vendored in ``figures/fonts``), sized so 8 pt prints at 8 pt at a 16 cm text width; no
|
|
4
|
+
in-figure titles (panel labels only); legends above the axes or direct labels; one fixed colour per stratum
|
|
5
|
+
and per tool (Okabe–Ito). Every figure is written as SVG with text as outlines (PDF build) and 300-dpi PNG.
|
|
6
|
+
Every share is a share of *AI-attributed* commits, a lower bound; captions say so.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import matplotlib as mpl
|
|
15
|
+
|
|
16
|
+
mpl.use("Agg")
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
import numpy as np
|
|
19
|
+
import polars as pl
|
|
20
|
+
from matplotlib import font_manager
|
|
21
|
+
from matplotlib.axes import Axes
|
|
22
|
+
from matplotlib.figure import Figure
|
|
23
|
+
|
|
24
|
+
from coauthored.analysis.hypotheses import wilson
|
|
25
|
+
|
|
26
|
+
ROOT = Path(__file__).resolve().parents[3]
|
|
27
|
+
FONT_DIR = ROOT / "figures" / "fonts"
|
|
28
|
+
INK, MUTED, GRID = "#1a1a1a", "#5a5a5a", "#e3e3e3"
|
|
29
|
+
STRATUM = {"S1": "#0072B2", "S2": "#D55E00", "S3": "#6e6e6e"}
|
|
30
|
+
STRATUM_NAME = {
|
|
31
|
+
"S1": "S1 model infrastructure",
|
|
32
|
+
"S2": "S2 coding-agent harnesses",
|
|
33
|
+
"S3": "S3 matched controls",
|
|
34
|
+
}
|
|
35
|
+
# the nine tools with the most attributed commits in 2025-01..2026-09 (all strata); the rest are "Other"
|
|
36
|
+
TOOL = {
|
|
37
|
+
"Claude Code": "#D55E00",
|
|
38
|
+
"GitHub Copilot": "#0072B2",
|
|
39
|
+
"OpenHands": "#56B4E9",
|
|
40
|
+
"Qwen Code": "#8C564B",
|
|
41
|
+
"OpenAI Codex": "#009E73",
|
|
42
|
+
"Gemini Code Assist": "#E69F00",
|
|
43
|
+
"Warp": "#F0E442",
|
|
44
|
+
"Cursor": "#CC79A7",
|
|
45
|
+
"OpenCode": "#332288",
|
|
46
|
+
}
|
|
47
|
+
OTHER = "#bdbdbd"
|
|
48
|
+
WIDTH = 16.0 / 2.54
|
|
49
|
+
DOT: dict[str, Any] = {"marker": "o", "ms": 3.6, "mec": "white", "mew": 0.6, "ls": "none"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def setup() -> None:
|
|
53
|
+
for p in sorted(FONT_DIR.glob("LibertinusSerif-*.otf")):
|
|
54
|
+
font_manager.fontManager.addfont(str(p))
|
|
55
|
+
plt.rcParams.update(
|
|
56
|
+
{
|
|
57
|
+
"svg.hashsalt": "coauthored", # stable element ids: byte-identical SVGs across runs
|
|
58
|
+
"font.family": "Libertinus Serif",
|
|
59
|
+
"font.size": 8.5,
|
|
60
|
+
"axes.labelsize": 8.5,
|
|
61
|
+
"xtick.labelsize": 8,
|
|
62
|
+
"ytick.labelsize": 8,
|
|
63
|
+
"legend.fontsize": 8,
|
|
64
|
+
"axes.spines.top": False,
|
|
65
|
+
"axes.spines.right": False,
|
|
66
|
+
"axes.linewidth": 0.6,
|
|
67
|
+
"axes.edgecolor": "#3a3a3a",
|
|
68
|
+
"axes.grid": True,
|
|
69
|
+
"axes.grid.axis": "y",
|
|
70
|
+
"grid.color": GRID,
|
|
71
|
+
"grid.linewidth": 0.6,
|
|
72
|
+
"axes.axisbelow": True,
|
|
73
|
+
"xtick.major.size": 2.5,
|
|
74
|
+
"ytick.major.size": 2.5,
|
|
75
|
+
"text.color": INK,
|
|
76
|
+
"axes.labelcolor": INK,
|
|
77
|
+
"legend.frameon": False,
|
|
78
|
+
"legend.handlelength": 1.6,
|
|
79
|
+
"svg.fonttype": "path",
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save(fig: Figure, out: Path, name: str) -> None:
|
|
85
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
fig.savefig(out / f"{name}.svg", bbox_inches="tight", pad_inches=0.02, metadata={"Date": None})
|
|
87
|
+
fig.savefig(out / f"{name}.png", dpi=300, bbox_inches="tight", pad_inches=0.04)
|
|
88
|
+
plt.close(fig)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def panel(ax: Axes, letter: str, text: str | None = None) -> None:
|
|
92
|
+
ax.annotate(
|
|
93
|
+
letter,
|
|
94
|
+
(0, 1),
|
|
95
|
+
xycoords="axes fraction",
|
|
96
|
+
xytext=(0, 12),
|
|
97
|
+
textcoords="offset points",
|
|
98
|
+
ha="left",
|
|
99
|
+
va="top",
|
|
100
|
+
fontsize=9,
|
|
101
|
+
weight="bold",
|
|
102
|
+
)
|
|
103
|
+
if text:
|
|
104
|
+
ax.annotate(
|
|
105
|
+
text,
|
|
106
|
+
(0, 1),
|
|
107
|
+
xycoords="axes fraction",
|
|
108
|
+
xytext=(14, 12),
|
|
109
|
+
textcoords="offset points",
|
|
110
|
+
ha="left",
|
|
111
|
+
va="top",
|
|
112
|
+
fontsize=8,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _pct(ax: Axes, axis: str = "y") -> None:
|
|
117
|
+
fmt = mpl.ticker.PercentFormatter(1.0, decimals=0)
|
|
118
|
+
(ax.yaxis if axis == "y" else ax.xaxis).set_major_formatter(fmt)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _months(lo: str, hi: str) -> list[str]:
|
|
122
|
+
y, m = map(int, lo.split("-"))
|
|
123
|
+
out = []
|
|
124
|
+
while f"{y}-{m:02d}" <= hi:
|
|
125
|
+
out.append(f"{y}-{m:02d}")
|
|
126
|
+
y, m = (y + 1, 1) if m == 12 else (y, m + 1)
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _month_ticks(ax: Axes, months: list[str]) -> None:
|
|
131
|
+
idx = [i for i, m in enumerate(months) if m[5:] in ("01", "07")]
|
|
132
|
+
ax.set_xticks(idx, [("Jan\n" + months[i][:4]) if months[i].endswith("-01") else "Jul" for i in idx])
|
|
133
|
+
ax.set_xlim(-0.5, len(months) - 0.5)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- F1 --------------------------------------------------------------------------------------------------
|
|
137
|
+
def _spread(ys: dict[str, float], gap: float) -> dict[str, float]:
|
|
138
|
+
"""Push end-of-line labels apart vertically so that none are closer than ``gap`` (data units)."""
|
|
139
|
+
order = sorted(ys, key=lambda k: ys[k])
|
|
140
|
+
out = {k: ys[k] for k in order}
|
|
141
|
+
for a, b in zip(order, order[1:], strict=False):
|
|
142
|
+
if out[b] - out[a] < gap:
|
|
143
|
+
out[b] = out[a] + gap
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def f1(series: pl.DataFrame, out: Path) -> None:
|
|
148
|
+
"""``series``: stratum, month, share, lo, hi (pooled, cluster-bootstrap band)."""
|
|
149
|
+
months = _months("2023-01", "2026-09")
|
|
150
|
+
fig, ax = plt.subplots(figsize=(WIDTH, 2.6))
|
|
151
|
+
ends: dict[str, float] = {}
|
|
152
|
+
for s, colour in STRATUM.items():
|
|
153
|
+
d = series.filter(pl.col("stratum") == s).sort("month")
|
|
154
|
+
x = [months.index(m) for m in d["month"]]
|
|
155
|
+
ax.fill_between(x, d["lo"], d["hi"], color=colour, alpha=0.15, lw=0)
|
|
156
|
+
ax.plot(x, d["share"], color=colour, lw=1.4)
|
|
157
|
+
ends[s] = float(d["share"][-1])
|
|
158
|
+
top = float(series["hi"].max()) # type: ignore[arg-type]
|
|
159
|
+
for s, y in _spread(ends, gap=0.075 * top).items():
|
|
160
|
+
ax.annotate(
|
|
161
|
+
STRATUM_NAME[s],
|
|
162
|
+
(len(months) - 1, y),
|
|
163
|
+
xytext=(5, 0),
|
|
164
|
+
textcoords="offset points",
|
|
165
|
+
va="center",
|
|
166
|
+
fontsize=8,
|
|
167
|
+
color=STRATUM[s],
|
|
168
|
+
annotation_clip=False,
|
|
169
|
+
)
|
|
170
|
+
_pct(ax)
|
|
171
|
+
ax.set_ylim(0, None)
|
|
172
|
+
ax.set_ylabel("AI-attributed share of commits")
|
|
173
|
+
_month_ticks(ax, months)
|
|
174
|
+
save(fig, out, "f1_monthly_share")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- F2 --------------------------------------------------------------------------------------------------
|
|
178
|
+
def f2(tq: pl.DataFrame, nq: pl.DataFrame, out: Path) -> None:
|
|
179
|
+
"""``tq``: stratum, q, tool, n_attr; ``nq``: stratum, q, n (all commits) — stacked share by tool."""
|
|
180
|
+
quarters = sorted(nq["q"].unique().to_list())
|
|
181
|
+
quarters = [q for q in quarters if q >= "2025-Q1"]
|
|
182
|
+
fig, axes = plt.subplots(1, 3, figsize=(WIDTH, 2.4), sharey=True)
|
|
183
|
+
for ax, (letter, s) in zip(axes, [("(a)", "S1"), ("(b)", "S2"), ("(c)", "S3")], strict=True):
|
|
184
|
+
n = {r["q"]: r["n"] for r in nq.filter(pl.col("stratum") == s).iter_rows(named=True)}
|
|
185
|
+
bottom = np.zeros(len(quarters))
|
|
186
|
+
d = tq.filter(pl.col("stratum") == s)
|
|
187
|
+
for tool in [*TOOL, "Other"]:
|
|
188
|
+
sel = (
|
|
189
|
+
d.filter(~pl.col("tool").is_in(list(TOOL)))
|
|
190
|
+
if tool == "Other"
|
|
191
|
+
else d.filter(pl.col("tool") == tool)
|
|
192
|
+
)
|
|
193
|
+
v = np.array(
|
|
194
|
+
[float(sel.filter(pl.col("q") == q)["n_attr"].sum()) / n.get(q, 1) for q in quarters]
|
|
195
|
+
)
|
|
196
|
+
ax.bar(
|
|
197
|
+
range(len(quarters)),
|
|
198
|
+
v,
|
|
199
|
+
bottom=bottom,
|
|
200
|
+
color=TOOL.get(tool, OTHER),
|
|
201
|
+
width=0.8,
|
|
202
|
+
lw=0,
|
|
203
|
+
label=tool,
|
|
204
|
+
)
|
|
205
|
+
bottom += v
|
|
206
|
+
ax.set_xticks(range(len(quarters)), [q.replace("-", "\n") for q in quarters], fontsize=7)
|
|
207
|
+
panel(ax, letter, STRATUM_NAME[s])
|
|
208
|
+
_pct(axes[0])
|
|
209
|
+
axes[0].set_ylabel("AI-attributed share of commits")
|
|
210
|
+
handles, labels = axes[0].get_legend_handles_labels()
|
|
211
|
+
fig.legend(handles, labels, loc="lower center", bbox_to_anchor=(0.5, 1.0), ncol=5, fontsize=7.5)
|
|
212
|
+
fig.tight_layout()
|
|
213
|
+
save(fig, out, "f2_tools")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# --- F3 --------------------------------------------------------------------------------------------------
|
|
217
|
+
def f3(rm: pl.DataFrame, order: list[tuple[str, str]], out: Path) -> None:
|
|
218
|
+
"""Repository × month heatmap of attributed share, 2025-01..2026-09, repositories grouped by stratum."""
|
|
219
|
+
months = _months("2025-01", "2026-09")
|
|
220
|
+
grid = np.full((len(order), len(months)), np.nan)
|
|
221
|
+
look = {(r["repo"], r["month"]): r for r in rm.iter_rows(named=True)}
|
|
222
|
+
for i, (_s, repo) in enumerate(order):
|
|
223
|
+
for j, m in enumerate(months):
|
|
224
|
+
r = look.get((repo, m))
|
|
225
|
+
if r and r["n"] >= 10:
|
|
226
|
+
grid[i, j] = r["n_attr"] / r["n"]
|
|
227
|
+
fig, ax = plt.subplots(figsize=(WIDTH, 0.105 * len(order) + 0.8))
|
|
228
|
+
cmap = plt.get_cmap("Blues").with_extremes(bad="#d9d9d9")
|
|
229
|
+
im = ax.imshow(grid, aspect="auto", cmap=cmap, vmin=0, vmax=0.5, interpolation="nearest")
|
|
230
|
+
ax.set_yticks(range(len(order)), [r for _s, r in order], fontsize=5.2)
|
|
231
|
+
ax.set_xticks(range(0, len(months), 3), [months[i] for i in range(0, len(months), 3)], fontsize=7)
|
|
232
|
+
ax.grid(False)
|
|
233
|
+
edges = [i for i in range(1, len(order)) if order[i][0] != order[i - 1][0]]
|
|
234
|
+
for e in edges:
|
|
235
|
+
ax.axhline(e - 0.5, color=INK, lw=0.8)
|
|
236
|
+
for s in STRATUM:
|
|
237
|
+
rows = [i for i, (t, _r) in enumerate(order) if t == s]
|
|
238
|
+
if rows:
|
|
239
|
+
ax.annotate(
|
|
240
|
+
s,
|
|
241
|
+
(len(months) - 0.5, (rows[0] + rows[-1]) / 2),
|
|
242
|
+
xytext=(6, 0),
|
|
243
|
+
textcoords="offset points",
|
|
244
|
+
va="center",
|
|
245
|
+
fontsize=8,
|
|
246
|
+
weight="bold",
|
|
247
|
+
color=STRATUM[s],
|
|
248
|
+
annotation_clip=False,
|
|
249
|
+
)
|
|
250
|
+
cb = fig.colorbar(im, ax=ax, orientation="horizontal", location="top", fraction=0.02, pad=0.01, aspect=50)
|
|
251
|
+
cb.ax.xaxis.set_major_formatter(mpl.ticker.PercentFormatter(1.0, decimals=0))
|
|
252
|
+
cb.ax.tick_params(labelsize=7)
|
|
253
|
+
cb.set_label("AI-attributed share (repository-months with ≥ 10 commits; grey = fewer)", fontsize=7.5)
|
|
254
|
+
save(fig, out, "f3_heatmap")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --- F4 --------------------------------------------------------------------------------------------------
|
|
258
|
+
def f4(per_repo: pl.DataFrame, out: Path) -> None:
|
|
259
|
+
"""``per_repo``: stratum, repo, n, n_attr for 2026-Q3 — sorted dots with Wilson 95% intervals.
|
|
260
|
+
|
|
261
|
+
S1 fills the left column; S2 sits above S3 on the right, rows equally spaced, one shared x scale.
|
|
262
|
+
"""
|
|
263
|
+
count = {s: per_repo.filter(pl.col("stratum") == s).height for s in STRATUM}
|
|
264
|
+
fig = plt.figure(figsize=(WIDTH, 0.105 * (count["S2"] + count["S3"]) + 1.0))
|
|
265
|
+
gs = fig.add_gridspec(2, 2, height_ratios=[count["S2"] + 1, count["S3"] + 1])
|
|
266
|
+
axes = {"S1": fig.add_subplot(gs[:, 0])}
|
|
267
|
+
axes["S3"] = fig.add_subplot(gs[1, 1], sharex=axes["S1"])
|
|
268
|
+
axes["S2"] = fig.add_subplot(gs[0, 1], sharex=axes["S1"])
|
|
269
|
+
for letter, s in [("(a)", "S1"), ("(b)", "S2"), ("(c)", "S3")]:
|
|
270
|
+
ax = axes[s]
|
|
271
|
+
d = per_repo.filter(pl.col("stratum") == s).with_columns((pl.col("n_attr") / pl.col("n")).alias("p"))
|
|
272
|
+
d = d.sort("p")
|
|
273
|
+
y = np.arange(d.height)
|
|
274
|
+
for i, r in enumerate(d.iter_rows(named=True)):
|
|
275
|
+
lo, hi = wilson(int(r["n_attr"]), int(r["n"]))
|
|
276
|
+
ax.plot([lo, hi], [i, i], color=STRATUM[s], lw=0.9, alpha=0.6)
|
|
277
|
+
ax.plot(d["p"], y, color=STRATUM[s], **DOT)
|
|
278
|
+
ax.set_yticks(y, d["repo"].to_list(), fontsize=5.6)
|
|
279
|
+
ax.set_ylim(-0.8, d.height - 0.2)
|
|
280
|
+
ax.grid(axis="x", color=GRID, lw=0.6)
|
|
281
|
+
ax.grid(axis="y", visible=False)
|
|
282
|
+
panel(ax, letter, STRATUM_NAME[s])
|
|
283
|
+
_pct(axes["S1"], "x")
|
|
284
|
+
axes["S1"].set_xlim(0, 1)
|
|
285
|
+
axes["S1"].set_xticks([0, 0.25, 0.5, 0.75, 1])
|
|
286
|
+
fig.supxlabel("AI-attributed share of commits, 2026-07..09 (Wilson 95% CI)", fontsize=8)
|
|
287
|
+
fig.tight_layout(w_pad=0.8, h_pad=0.6)
|
|
288
|
+
save(fig, out, "f4_per_repo")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- F5 --------------------------------------------------------------------------------------------------
|
|
292
|
+
def f5(
|
|
293
|
+
by_method: dict[str, tuple[int, int, tuple[float, float]]], overall: tuple[int, int], out: Path
|
|
294
|
+
) -> None:
|
|
295
|
+
order = [m for m in ("squash", "merge", "rebase", "single-commit-ambiguous") if m in by_method]
|
|
296
|
+
labels = {
|
|
297
|
+
"squash": "Squash",
|
|
298
|
+
"merge": "Merge commit",
|
|
299
|
+
"rebase": "Rebase",
|
|
300
|
+
"single-commit-ambiguous": "Single commit",
|
|
301
|
+
}
|
|
302
|
+
fig, ax = plt.subplots(figsize=(WIDTH * 0.6, 1.9))
|
|
303
|
+
rows = [("All", *overall, wilson(*overall))] + [(labels[m], *by_method[m]) for m in order]
|
|
304
|
+
for i, (_name, k, n, (lo, hi)) in enumerate(rows):
|
|
305
|
+
p = k / n if n else float("nan")
|
|
306
|
+
ax.plot([lo, hi], [i, i], color=STRATUM["S1"], lw=1.2)
|
|
307
|
+
ax.plot([p], [i], color=STRATUM["S1"], **DOT)
|
|
308
|
+
ax.annotate(
|
|
309
|
+
f"{k}/{n}",
|
|
310
|
+
(1.0, i),
|
|
311
|
+
xytext=(4, 0),
|
|
312
|
+
textcoords="offset points",
|
|
313
|
+
va="center",
|
|
314
|
+
fontsize=7.5,
|
|
315
|
+
color=MUTED,
|
|
316
|
+
annotation_clip=False,
|
|
317
|
+
)
|
|
318
|
+
ax.set_yticks(range(len(rows)), [r[0] for r in rows])
|
|
319
|
+
ax.invert_yaxis()
|
|
320
|
+
ax.set_xlim(0, 1)
|
|
321
|
+
_pct(ax, "x")
|
|
322
|
+
ax.grid(axis="x", color=GRID, lw=0.6)
|
|
323
|
+
ax.grid(axis="y", visible=False)
|
|
324
|
+
ax.set_xlabel("Attribution kept on the default branch (Wilson 95% CI)")
|
|
325
|
+
save(fig, out, "f5_survival")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- F6 --------------------------------------------------------------------------------------------------
|
|
329
|
+
def f6(per_repo: pl.DataFrame, out: Path) -> None:
|
|
330
|
+
"""``per_repo``: stratum, repo, n, n_attr, policy (2026-Q3)."""
|
|
331
|
+
classes = ["bans", "requires_disclosure", "mentions", "silent"]
|
|
332
|
+
names = {
|
|
333
|
+
"bans": "Bans",
|
|
334
|
+
"requires_disclosure": "Requires\ndisclosure",
|
|
335
|
+
"mentions": "Mentions,\nno rule",
|
|
336
|
+
"silent": "Silent",
|
|
337
|
+
}
|
|
338
|
+
rng = np.random.default_rng(20261028)
|
|
339
|
+
fig, ax = plt.subplots(figsize=(WIDTH * 0.7, 2.3))
|
|
340
|
+
for j, c in enumerate(classes):
|
|
341
|
+
d = per_repo.filter(pl.col("policy") == c)
|
|
342
|
+
for s in STRATUM:
|
|
343
|
+
e = d.filter(pl.col("stratum") == s)
|
|
344
|
+
if e.height:
|
|
345
|
+
x = j + rng.uniform(-0.18, 0.18, e.height) + {"S1": -0.08, "S2": 0.0, "S3": 0.08}[s]
|
|
346
|
+
ax.plot(x, e["n_attr"] / e["n"], color=STRATUM[s], **DOT)
|
|
347
|
+
if d.height:
|
|
348
|
+
pooled = float(d["n_attr"].sum()) / float(d["n"].sum())
|
|
349
|
+
ax.plot([j - 0.3, j + 0.3], [pooled, pooled], color=INK, lw=1.2)
|
|
350
|
+
ax.set_xticks(range(len(classes)), [names[c] for c in classes])
|
|
351
|
+
_pct(ax)
|
|
352
|
+
ax.set_ylabel("AI-attributed share, 2026-07..09")
|
|
353
|
+
handles = [mpl.lines.Line2D([], [], color=STRATUM[s], **DOT) for s in STRATUM]
|
|
354
|
+
ax.legend(handles, list(STRATUM), loc="lower left", bbox_to_anchor=(0, 1.0), ncol=3)
|
|
355
|
+
save(fig, out, "f6_policy")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --- F8 --------------------------------------------------------------------------------------------------
|
|
359
|
+
def f8(per_repo: pl.DataFrame, out: Path) -> None:
|
|
360
|
+
"""``per_repo``: stratum, repo, share, breadth (2026-Q3) — breadth vs intensity."""
|
|
361
|
+
fig, ax = plt.subplots(figsize=(WIDTH * 0.6, 2.6))
|
|
362
|
+
for s in STRATUM:
|
|
363
|
+
d = per_repo.filter(pl.col("stratum") == s)
|
|
364
|
+
ax.plot(d["breadth"], d["share"], color=STRATUM[s], label=STRATUM_NAME[s], **DOT)
|
|
365
|
+
lim = max(float(per_repo["breadth"].max()), float(per_repo["share"].max())) * 1.05 # type: ignore[arg-type]
|
|
366
|
+
ax.plot([0, lim], [0, lim], color=MUTED, lw=0.6, ls=(0, (3, 3)))
|
|
367
|
+
ax.set_xlim(0, lim)
|
|
368
|
+
ax.set_ylim(0, lim)
|
|
369
|
+
_pct(ax)
|
|
370
|
+
_pct(ax, "x")
|
|
371
|
+
ax.grid(axis="x", color=GRID, lw=0.6)
|
|
372
|
+
ax.set_xlabel("Breadth: active authors with ≥ 1 attributed commit")
|
|
373
|
+
ax.set_ylabel("Intensity: attributed share of commits")
|
|
374
|
+
ax.legend(loc="lower left", bbox_to_anchor=(0, 1.0), ncol=1, fontsize=7.5)
|
|
375
|
+
save(fig, out, "f8_breadth")
|