yield-audit 0.3.2__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.
@@ -0,0 +1,3 @@
1
+ """yield-audit: outcome accounting for AI coding agents."""
2
+
3
+ __version__ = "0.3.2"
@@ -0,0 +1,8 @@
1
+ """python -m yield_audit entry point."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,117 @@
1
+ """Session-to-commit attribution with explicit confidence grades.
2
+
3
+ Attribution is probabilistic by nature, so every pair carries a grade and the
4
+ report states it. Rules (v0.1, deterministic):
5
+
6
+ - Candidates: sessions overlapping the commit date (session must start no more
7
+ than ``grace`` after the commit, and end no earlier than ``window`` before
8
+ it) that share at least one edited file with the commit.
9
+ - ``high`` — the session itself ran ``git commit`` AND shares edited files.
10
+ - ``medium``— time proximity + shared edited files.
11
+ - A commit claimed by several same-grade sessions is split evenly across them
12
+ and flagged ambiguous; higher grades win over lower grades regardless of
13
+ overlap counts.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from datetime import timedelta
20
+
21
+ from .events import Session
22
+ from .gitdata import CommitInfo
23
+
24
+ GRADES = ("high", "medium", "none")
25
+
26
+
27
+ @dataclass
28
+ class Attribution:
29
+ session_id: str
30
+ commit_sha: str
31
+ grade: str
32
+ share: float # 1.0, or 1/n when contested by equal-grade sessions
33
+ shared_files: list[str]
34
+
35
+
36
+ @dataclass
37
+ class AttributionResult:
38
+ pairs: list[Attribution]
39
+ ambiguous_commits: list[str]
40
+ claimed_shas: set[str]
41
+ unclaimed_commits: list[str]
42
+
43
+ def for_session(self, session_id: str) -> list[Attribution]:
44
+ return [p for p in self.pairs if p.session_id == session_id]
45
+
46
+
47
+ def attribute(
48
+ sessions: list[Session],
49
+ commits: list[CommitInfo],
50
+ *,
51
+ proximity: timedelta = timedelta(hours=24),
52
+ start_grace: timedelta = timedelta(minutes=5),
53
+ ) -> AttributionResult:
54
+ # Commit files as sets, sessions' edited files as sets.
55
+ commit_files = {c.sha: set(c.files) for c in commits}
56
+ session_files = {s.session_id: set(s.edited_files) for s in sessions}
57
+
58
+ claims: dict[str, list[tuple[str, str, list[str]]]] = {} # sha -> [(session_id, grade, shared_files)]
59
+ for commit in commits:
60
+ for session in sessions:
61
+ edited = session_files[session.session_id]
62
+ if not edited:
63
+ continue
64
+ overlap = edited & commit_files[commit.sha]
65
+ if not overlap:
66
+ continue
67
+ if session.start > commit.date + start_grace:
68
+ continue
69
+ if session.end < commit.date - proximity:
70
+ continue
71
+ grade = "high" if session.ran_git_commit else "medium"
72
+ claims.setdefault(commit.sha, []).append(
73
+ (session.session_id, grade, sorted(overlap))
74
+ )
75
+
76
+ pairs: list[Attribution] = []
77
+ ambiguous: list[str] = []
78
+ for commit in commits:
79
+ claim_list = claims.get(commit.sha, [])
80
+ if not claim_list:
81
+ continue
82
+ best_grade = min((g for _, g, _ in claim_list), key=GRADES.index)
83
+ # Every same-grade claimant keeps a stake, split evenly: dropping
84
+ # lower-overlap claimants would silently erase their output from
85
+ # share-weighted aggregates downstream. Overlap counts do not weigh
86
+ # the split — under v0.1 heuristics any weighting would be fake
87
+ # precision.
88
+ winners = [(sid, grade, shared) for sid, grade, shared in claim_list if grade == best_grade]
89
+ winners.sort(key=lambda item: item[0]) # deterministic order
90
+ share = 1.0
91
+ if len(winners) > 1:
92
+ ambiguous.append(commit.sha)
93
+ share = 1.0 / len(winners)
94
+ for sid, grade, shared in winners:
95
+ pairs.append(
96
+ Attribution(
97
+ session_id=sid,
98
+ commit_sha=commit.sha,
99
+ grade=grade,
100
+ share=share,
101
+ shared_files=shared,
102
+ )
103
+ )
104
+
105
+ pairs.sort(key=lambda p: (p.commit_sha, p.session_id))
106
+ claimed = {p.commit_sha for p in pairs}
107
+ unclaimed = [c.sha for c in commits if c.sha not in claimed]
108
+ return AttributionResult(
109
+ pairs=pairs,
110
+ ambiguous_commits=sorted(ambiguous),
111
+ claimed_shas=claimed,
112
+ unclaimed_commits=unclaimed,
113
+ )
114
+
115
+
116
+ def commits_by_sha(commits: list[CommitInfo]) -> dict[str, CommitInfo]:
117
+ return {c.sha: c for c in commits}
yield_audit/audit.py ADDED
@@ -0,0 +1,453 @@
1
+ """Audit pipeline: normalize inputs, run lenses, assemble the report dict.
2
+
3
+ Every metric block carries a ``measurement`` label so no reader can mistake
4
+ an estimate for an observation:
5
+
6
+ - ``observed`` — read straight from local records (token counts, commands).
7
+ - ``estimate`` — observed values × list-price assumptions (USD figures).
8
+ - ``proxy`` — a stated substitute stands in for an unobservable quantity
9
+ (e.g. line-share standing in for per-commit token share).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+
17
+ from . import cohorts, gitdata, redact, transcripts
18
+ from .attribute import attribute as attribute_fn
19
+ from .costs import session_cost
20
+ from .lenses.accepted import analyze_accepted
21
+ from .lenses.cache_locality import analyze_cache_locality
22
+ from .lenses.retry import analyze_retry
23
+ from .lenses.rework import analyze_rework
24
+ from .lenses.survival import analyze_survival
25
+ from .lenses.verify_gap import analyze_verify_gap
26
+ from .lenses.waste import analyze_waste
27
+ from .pricing import load_pricing
28
+
29
+ SCHEMA_VERSION = "yieldaudit.report.v1"
30
+
31
+
32
+ class AuditError(RuntimeError):
33
+ """User-facing configuration/environment problem."""
34
+
35
+
36
+ def run_audit(
37
+ *,
38
+ repo: str,
39
+ transcripts_root: Path | None,
40
+ days: int,
41
+ horizons: tuple[int, ...],
42
+ headline_horizon: int,
43
+ now: datetime,
44
+ pricing_override: str | None,
45
+ proximity_hours: int,
46
+ show_paths: bool,
47
+ details: bool,
48
+ agents=None,
49
+ rework_days: int = 14,
50
+ log=None,
51
+ ) -> dict:
52
+ if not gitdata.is_git_repo(repo):
53
+ raise AuditError(f"not a git repository: {repo}")
54
+ if headline_horizon not in horizons:
55
+ horizons = (headline_horizon, *horizons)
56
+
57
+ repo_real = transcripts.normalize_path(repo)
58
+ log_messages: list[str] = []
59
+ if transcripts_root is not None and not Path(transcripts_root).is_dir():
60
+ raise AuditError(f"transcripts dir not found: {transcripts_root}")
61
+ roots = transcripts.resolve_roots(agents or transcripts.DEFAULT_AGENTS, transcripts_root)
62
+ if not roots:
63
+ tried = ", ".join(
64
+ f"{name} {transcripts.ADAPTERS[name].default_root()}"
65
+ for name in sorted(agents or transcripts.DEFAULT_AGENTS)
66
+ )
67
+ raise AuditError(
68
+ f"no agent transcripts found (tried: {tried}) — "
69
+ "pass --transcripts-dir or run `yield-audit doctor`"
70
+ )
71
+ for name in sorted(set(transcripts.DEFAULT_AGENTS) - set(roots)):
72
+ log_messages.append(f"agent {name}: transcripts root not found, skipped")
73
+ if log is not None:
74
+ sessions = transcripts.load_sessions(
75
+ repo_real, transcripts_root, now=now, days=days, agents=agents,
76
+ logger=lambda m: (log_messages.append(m), log(m))[1],
77
+ )
78
+ else:
79
+ sessions = transcripts.load_sessions(
80
+ repo_real, transcripts_root, now=now, days=days, agents=agents,
81
+ logger=log_messages.append,
82
+ )
83
+ if not sessions:
84
+ log_messages.append(
85
+ "0 agent sessions matched this repo in the window — "
86
+ "run `yield-audit doctor --repo <repo>` to check transcript discovery"
87
+ )
88
+ transcripts.group_edit_files_by_repo(sessions, repo_real)
89
+
90
+ since = now - timedelta(days=days) if days and days > 0 else None
91
+ git_warnings: list[str] = []
92
+ commits = gitdata.commits_with_numstat(repo_real, since=since, until=None, warnings=git_warnings)
93
+ commits_by_sha = {c.sha: c for c in commits}
94
+
95
+ attributions = attribute_fn(
96
+ sessions,
97
+ commits,
98
+ proximity=timedelta(hours=proximity_hours),
99
+ )
100
+
101
+ messages = gitdata.commit_messages(repo_real, since=since, until=None)
102
+ cohort_labels = cohorts.label_commits(messages, attributions.claimed_shas)
103
+
104
+ table, fallback, pricing_notes = load_pricing(pricing_override)
105
+
106
+ session_costs = {}
107
+ cost_objects = {}
108
+ for session in sessions:
109
+ cost = session_cost(session, table, fallback)
110
+ cost_objects[session.session_id] = cost
111
+ session_costs[session.session_id] = {
112
+ "cost_usd": cost.cost_usd,
113
+ "total_tokens": cost.total_input_tokens + cost.output_tokens,
114
+ }
115
+
116
+ # One shared cache across lenses: blame/snapshot results plus the
117
+ # single touch-map pass that lets both skip blaming files no commit
118
+ # touched inside the measurement window.
119
+ blame_cache: dict = {}
120
+ survival = analyze_survival(
121
+ repo_real,
122
+ attributions,
123
+ commits_by_sha,
124
+ now=now,
125
+ horizons=horizons,
126
+ headline_horizon=headline_horizon,
127
+ blame_cache=blame_cache,
128
+ touch_since=since,
129
+ )
130
+ waste = analyze_waste(survival, {sid: c["cost_usd"] for sid, c in session_costs.items()}, headline_horizon)
131
+
132
+ commit_dates: dict[str, datetime] = {}
133
+ committed_ids: set[str] = set()
134
+ for pair in attributions.pairs:
135
+ committed_ids.add(pair.session_id)
136
+ commit = commits_by_sha.get(pair.commit_sha)
137
+ if commit is not None:
138
+ prev = commit_dates.get(pair.session_id)
139
+ commit_dates[pair.session_id] = max(prev, commit.date) if prev else commit.date
140
+
141
+ survival_rates = {
142
+ sid: info["rate"] if info["added"] > 0 else None
143
+ for sid, info in survival.sessions.items()
144
+ }
145
+ verify = analyze_verify_gap(sessions, commit_dates, survival_rates)
146
+ accepted = analyze_accepted(session_costs, survival.sessions, committed_ids)
147
+
148
+ retry_by_session = {s.session_id: analyze_retry(s) for s in sessions}
149
+ cache_by_session = {
150
+ s.session_id: analyze_cache_locality(s, table, fallback) for s in sessions
151
+ }
152
+
153
+ rework = analyze_rework(
154
+ repo_real,
155
+ commits,
156
+ cohort_labels,
157
+ now=now,
158
+ horizon_days=rework_days,
159
+ blame_cache=blame_cache,
160
+ touch_since=since,
161
+ )
162
+
163
+ unknown_models: set[str] = set()
164
+ for cost in cost_objects.values():
165
+ unknown_models |= cost.unknown_models
166
+
167
+ report = {
168
+ "schema_version": SCHEMA_VERSION,
169
+ "generated_at": now.astimezone(timezone.utc).isoformat(),
170
+ "parameters": {
171
+ "repo": repo_real if show_paths else redact.abbreviate_home(repo_real),
172
+ "window_days": days,
173
+ "horizons_days": list(horizons),
174
+ "headline_horizon_days": headline_horizon,
175
+ "agents_scanned": sorted(roots),
176
+ "transcripts_root": redact.abbreviate_home(str(roots[sorted(roots)[0]])) if roots else "",
177
+ "transcripts_roots": {
178
+ name: redact.abbreviate_home(str(root)) for name, root in sorted(roots.items())
179
+ },
180
+ "attribution_proximity_hours": proximity_hours,
181
+ "rework_horizon_days": rework_days,
182
+ "pricing_source": "builtin_2026-09" if not pricing_override else str(pricing_override),
183
+ },
184
+ "input": {
185
+ "sessions": len(sessions),
186
+ "api_calls": sum(len(s.api_calls) for s in sessions),
187
+ "commits_in_window": len(commits),
188
+ "attributed_commits": len(attributions.claimed_shas),
189
+ "unclaimed_commits": len(attributions.unclaimed_commits),
190
+ "ambiguous_commits": attributions.ambiguous_commits,
191
+ "unknown_models": sorted(redact.sanitize_text(m) for m in unknown_models),
192
+ },
193
+ "attribution": _attribution_block(attributions),
194
+ "m1_survival": _survival_block(survival, show_paths, details),
195
+ "m2_waste": _waste_block(waste),
196
+ "m3_retry": _retry_block(retry_by_session, show_paths),
197
+ "m4_accepted": _accepted_block(accepted),
198
+ "m5_cache": _cache_block(cache_by_session),
199
+ "m8_verify": _verify_block(verify),
200
+ "m11_rework": _rework_block(rework, details),
201
+ "notes": _global_notes(pricing_notes) + git_warnings + log_messages[:20],
202
+ }
203
+ # Defense in depth: nothing in the report bypasses the output boundary,
204
+ # even if a future field forgets to sanitize.
205
+ return redact.deep_sanitize(report)
206
+
207
+
208
+ def _attribution_block(attributions) -> dict:
209
+ grades: dict[str, int] = {}
210
+ for pair in attributions.pairs:
211
+ grades[pair.grade] = grades.get(pair.grade, 0) + 1
212
+ return {
213
+ "measurement": "heuristic_matching_with_confidence_grades",
214
+ "pairs": len(attributions.pairs),
215
+ "grades": grades,
216
+ "ambiguous_commits": attributions.ambiguous_commits,
217
+ "note": "high = session ran the commit itself; medium = shared edited files within the time window; contested commits are split and flagged",
218
+ }
219
+
220
+
221
+ def _survival_block(survival, show_paths: bool, details: bool) -> dict:
222
+ block = {
223
+ "measurement": "measured_from_git_history",
224
+ "horizon_days": survival.horizon,
225
+ "overall_rate": _round(survival.overall),
226
+ "added_lines": _num(survival.overall_added),
227
+ "survived_lines": _num(survival.overall_survived),
228
+ "pending_units": survival.pending_count,
229
+ "by_kind": {
230
+ kind: {
231
+ "added": _num(info["added"]),
232
+ "survived": _num(info["survived"]),
233
+ "rate": _round(info["rate"]),
234
+ }
235
+ for kind, info in survival.by_kind.items()
236
+ },
237
+ "per_session": {
238
+ _sid(sid): {
239
+ "added": _num(info["added"]),
240
+ "survived": _num(info["survived"]),
241
+ "rate": _round(info["rate"]),
242
+ "pending": info["pending"],
243
+ }
244
+ for sid, info in sorted(survival.sessions.items())
245
+ },
246
+ "notes": survival.notes,
247
+ }
248
+ if details:
249
+ block["units"] = [
250
+ {
251
+ "session": _sid(u.session_id),
252
+ "path": redact.redact_path(u.path, show_paths=show_paths),
253
+ "kind": u.kind,
254
+ "added": u.added,
255
+ "survived": u.survived.get(survival.horizon),
256
+ "deleted": u.deleted.get(survival.horizon),
257
+ "pending": survival.horizon in u.pending_horizons,
258
+ }
259
+ for u in survival.units
260
+ ]
261
+ return block
262
+
263
+
264
+ def _waste_block(waste) -> dict:
265
+ total_lower = sum(b.lower_usd for b in waste.values())
266
+ total_upper = sum(b.upper_usd for b in waste.values())
267
+ block = {
268
+ "measurement": "estimate_with_bounds",
269
+ "total_lower_usd": round(total_lower, 6),
270
+ "total_upper_usd": round(total_upper, 6),
271
+ "method": "session cost x attribution-share-weighted line-share proxy x waste class (removed=lower+upper, rewritten>=50% lost=upper only)",
272
+ "per_session": {
273
+ _sid(sid): {
274
+ "lower_usd": round(b.lower_usd, 6),
275
+ "upper_usd": round(b.upper_usd, 6),
276
+ "removed_lines": _num(b.removed_lines),
277
+ "rewritten_lines": _num(b.rewritten_lines),
278
+ "edited_lines": _num(b.edited_lines),
279
+ }
280
+ for sid, b in sorted(waste.items())
281
+ },
282
+ }
283
+ return block
284
+
285
+
286
+ def _retry_block(retry_by_session, show_paths: bool) -> dict:
287
+ total_tax_tokens = sum(r.tax_tokens for r in retry_by_session.values())
288
+ total_tokens = sum(r.total_tokens for r in retry_by_session.values())
289
+ chains = [
290
+ {
291
+ "session": _sid(sid),
292
+ "command": redact.sanitize_command(c.command, show_paths=show_paths, limit=80),
293
+ "attempts": c.attempts,
294
+ "errors": c.errors,
295
+ }
296
+ for sid, r in sorted(retry_by_session.items())
297
+ for c in r.chains
298
+ ]
299
+ return {
300
+ "measurement": "observed_from_transcripts",
301
+ "total_tax_tokens": total_tax_tokens,
302
+ "total_tokens": total_tokens,
303
+ "tax_share": _round(total_tax_tokens / total_tokens) if total_tokens else None,
304
+ "failure_chains": chains[:200],
305
+ "chains_truncated": max(0, len(chains) - 200),
306
+ "per_session": {
307
+ _sid(sid): {
308
+ "tax_tokens": r.tax_tokens,
309
+ "tax_share": _round(r.tax_token_share),
310
+ "chains": len(r.chains),
311
+ }
312
+ for sid, r in sorted(retry_by_session.items())
313
+ if r.chains
314
+ },
315
+ }
316
+
317
+
318
+ def _accepted_block(accepted) -> dict:
319
+ return {
320
+ "measurement": "estimate_observed_tokens_x_list_price",
321
+ "cost_per_accepted_usd": _round(accepted.cost_per_accepted_usd, 6),
322
+ "tokens_per_accepted": accepted.tokens_per_accepted,
323
+ "accept_threshold_survival": 0.5,
324
+ "totals": {
325
+ status: {
326
+ "sessions": info["sessions"],
327
+ "cost_usd": _round(info["cost_usd"], 6),
328
+ "total_tokens": info["total_tokens"],
329
+ }
330
+ for status, info in accepted.totals.items()
331
+ },
332
+ "per_session": {
333
+ _sid(sid): {
334
+ "status": info["status"],
335
+ "cost_usd": _round(info["cost_usd"], 6),
336
+ "survival_rate": _round(info["survival_rate"]),
337
+ }
338
+ for sid, info in sorted(accepted.sessions.items())
339
+ },
340
+ }
341
+
342
+
343
+ def _cache_block(cache_by_session) -> dict:
344
+ total_wasted = sum(r.wasted_usd for r in cache_by_session.values())
345
+ cold_total = sum(r.cold_calls for r in cache_by_session.values())
346
+ by_class: dict[str, int] = {}
347
+ for r in cache_by_session.values():
348
+ for cls, count in r.by_class.items():
349
+ by_class[cls] = by_class.get(cls, 0) + count
350
+ rates = [r.hit_rate for r in cache_by_session.values() if r.hit_rate is not None]
351
+ events = []
352
+ for sid, r in sorted(cache_by_session.items()):
353
+ for e in r.events:
354
+ if e.class_name == "compaction":
355
+ continue
356
+ events.append(
357
+ {
358
+ "session": _sid(sid),
359
+ "ts": e.ts.isoformat(),
360
+ "class": e.class_name,
361
+ "gap_seconds": round(e.gap_seconds) if e.gap_seconds is not None else None,
362
+ "input_tokens": e.input_tokens,
363
+ "wasted_usd": _round(e.wasted_usd, 6),
364
+ }
365
+ )
366
+ return {
367
+ "measurement": "estimate_observed_tokens_x_list_price",
368
+ "cold_calls": cold_total,
369
+ "cold_by_class": by_class,
370
+ "wasted_usd": _round(total_wasted, 6),
371
+ "mean_session_hit_rate": _round(sum(rates) / len(rates)) if rates else None,
372
+ "events": events[:200],
373
+ "events_truncated": max(0, len(events) - 200),
374
+ "notes": [
375
+ "wasted_usd = what non-compaction cold input would have cost at cache-read price; compaction rebuilds are excluded by design"
376
+ ],
377
+ }
378
+
379
+
380
+ def _rework_block(rework, details: bool) -> dict:
381
+ block = {
382
+ "measurement": "measured_from_git_history",
383
+ "rework_horizon_days": rework.horizon_days,
384
+ "cohort_evidence": dict(sorted(rework.evidence.items())),
385
+ "cohorts": {
386
+ label: {
387
+ "commits": info["commits"],
388
+ "measured_commits": info["measured_commits"],
389
+ "pending_commits": info["pending_commits"],
390
+ "added_lines": _num(info["added"]),
391
+ "reworked_lines": _num(info["reworked"]),
392
+ "rework_rate": _round(info["rate"]),
393
+ }
394
+ for label, info in sorted(rework.cohorts.items())
395
+ },
396
+ "notes": rework.notes,
397
+ }
398
+ if details:
399
+ block["commits"] = rework.commits[:200]
400
+ block["commits_truncated"] = max(0, len(rework.commits) - 200)
401
+ return block
402
+
403
+
404
+ def _verify_block(verify) -> dict:
405
+ return {
406
+ "measurement": "observed_from_transcripts",
407
+ "gap_rate": _round(verify.gap_rate),
408
+ "gap_rate_strict": _round(verify.gap_rate_strict),
409
+ "per_session": {
410
+ _sid(sid): {
411
+ "status": info.status,
412
+ "verify_commands": info.verify_count,
413
+ }
414
+ for sid, info in sorted(verify.sessions.items())
415
+ },
416
+ "correlation_with_survival": {
417
+ status: {
418
+ "sessions": info["sessions"],
419
+ "mean_survival": _round(info["mean_survival"]),
420
+ }
421
+ for status, info in verify.correlation.items()
422
+ },
423
+ "notes": verify.notes,
424
+ }
425
+
426
+
427
+ def _global_notes(pricing_notes: list[str]) -> list[str]:
428
+ return [
429
+ "all data stays local: transcripts and git history are read, nothing is uploaded",
430
+ "USD figures multiply observed token counts by list prices; your actual rates may differ (override with --pricing-file)",
431
+ "attribution is heuristic; every dependent metric inherits its confidence grades",
432
+ ] + pricing_notes
433
+
434
+
435
+ def _round(value, digits: int = 6):
436
+ return round(value, digits) if isinstance(value, float) else value
437
+
438
+
439
+ def _num(value):
440
+ """Collapse integral floats (share-weighted sums) to int for display."""
441
+ if isinstance(value, float) and value.is_integer():
442
+ return int(value)
443
+ return value
444
+
445
+
446
+ def _sid(session_id: str) -> str:
447
+ # Session ids are transcript-controlled; they end up as dict keys and
448
+ # table cells, so they pass the same sanitization as everything else.
449
+ # Vendor namespaced ids ("claude:abcd…") keep their prefix so reports
450
+ # stay unambiguous in multi-agent audits.
451
+ safe = redact.sanitize_text(session_id)
452
+ vendor, sep, rest = safe.partition(":")
453
+ return f"{vendor}:{rest[:8]}" if sep else safe[:8]