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 ADDED
@@ -0,0 +1,3 @@
1
+ """coauthored: count AI-attributed commits in git history."""
2
+
3
+ __version__ = "0.1.0"
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")