sediment-api 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.
@@ -0,0 +1,438 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Label-confidence inspection — a stratified sample of attributed_completions with
3
+ ``resolve_confidence``'s factor breakdown, so an operator can eyeball what
4
+ the label-confidence policy actually produced without writing Python. NVIDIA's
5
+ agentic-RL guidance treats "run the reward function against 50-100 outputs
6
+ and manually inspect the scores" as mandatory pre-training hygiene; this is
7
+ that hygiene, wired to ``sediment_export.generate_label_confidence_inspection``.
8
+
9
+ Prints an aligned table on stdout by default, or ``--json`` for machines.
10
+ An empty result (no data for the org) is a valid answer — it prints
11
+ "no data" and exits 0, not an error, same convention as
12
+ ``model_report.py``.
13
+
14
+ sediment report label-confidence-inspection --org acme-corp
15
+ sediment report label-confidence-inspection --org acme-corp --n 100
16
+ sediment report label-confidence-inspection --org acme-corp --latency-buckets 4
17
+ sediment report label-confidence-inspection --org acme-corp --json
18
+ sediment report label-confidence-inspection --org acme-corp --sensitivity
19
+ sediment report label-confidence-inspection --org acme-corp --sensitivity \\
20
+ --knob implicit_accept_multiplier --values 1.0,1.1,1.5
21
+
22
+ Every row carries a ``human_judgment`` field that is always empty/null — a
23
+ column for a human to fill in later against real data. This tool never
24
+ generates a judgment and labels it as if it were the human's (see
25
+ ``sediment_export.label_confidence_inspection``'s module docstring).
26
+
27
+ Storage is read the same way ``model_report.py`` reads it —
28
+ ``--database-url``/``SEDIMENT_DATABASE_URL`` and ``--mirror-path``/
29
+ ``SEDIMENT_MIRROR_PATH`` — and, like that script, this one does NOT import
30
+ ``sediment_api.config.settings`` (which requires ``SEDIMENT_ORG_ID`` and,
31
+ outside dev mode, real auth secrets just to construct): report any org a
32
+ deployment holds via ``--org``, without touching API auth config.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import os
40
+ import sys
41
+ from dataclasses import asdict
42
+
43
+ from sediment_core import normalize_org_id
44
+ from sediment_derive import MirrorManager
45
+ from sediment_export import (
46
+ DEFAULT_SWEEP_GRIDS,
47
+ LABEL_CONFIDENCE_POLICY_KNOBS,
48
+ DecisionLatencyReport,
49
+ InspectionRow,
50
+ LabelConfidenceSensitivityRow,
51
+ SFTPolicy,
52
+ generate_decision_latency_report,
53
+ generate_label_confidence_inspection,
54
+ generate_label_confidence_sensitivity,
55
+ )
56
+
57
+ from ..database import add_database_url_argument, one_shot_fact_store
58
+
59
+ _DEFAULT_MIRROR_PATH = "./mirrors"
60
+ _DEFAULT_N = 50
61
+ _DEFAULT_MIN_CONFIDENCE = 0.6
62
+
63
+
64
+ def _decisions_str(row: InspectionRow) -> str:
65
+ if not row.decisions:
66
+ return "-"
67
+ return "; ".join(
68
+ f"{d.agent_harness}/{'explicit' if d.explicit else 'implicit'}/"
69
+ f"{'accept' if d.accepted else 'reject'}"
70
+ for d in row.decisions
71
+ )
72
+
73
+
74
+ def _ci_str(row: InspectionRow) -> str:
75
+ if not row.ci_outcomes:
76
+ return "-"
77
+ return "; ".join(o.result for o in row.ci_outcomes)
78
+
79
+
80
+ def _confidence_str(row: InspectionRow) -> str:
81
+ if row.confidence is None:
82
+ return "None"
83
+ b = row.confidence
84
+ return (
85
+ f"{b.final:.3f} "
86
+ f"(decision={b.decision_factor:.3f} x ci={b.ci_factor:.3f} "
87
+ f"x reliability={b.ci_reliability:.3f} "
88
+ f"x sim={b.similarity_discount:.3f})"
89
+ )
90
+
91
+
92
+ def _print_table(rows: list[InspectionRow]) -> None:
93
+ if not rows:
94
+ print("no data")
95
+ return
96
+ for row in rows:
97
+ print(
98
+ f"--- recipe {row.recipe_id} v{row.recipe_version} "
99
+ f"eligibility_source={row.eligibility_source or 'ineligible'}"
100
+ )
101
+ if row.attribution_source == "abandonment":
102
+ print(f" abandonment {row.inference_call_id}")
103
+ else:
104
+ assert row.repo is not None
105
+ assert row.commit_sha is not None
106
+ assert row.file_path is not None
107
+ print(f"--- {row.repo}@{row.commit_sha[:12]} {row.file_path}")
108
+ print(f" inference_call_id: {row.inference_call_id}")
109
+ print(f" snippet: {row.completion_snippet!r}")
110
+ print(f" decisions: {_decisions_str(row)}")
111
+ print(f" ci_outcomes: {_ci_str(row)}")
112
+ if row.attribution_source == "abandonment":
113
+ print(" evidence: abandonment")
114
+ else:
115
+ assert row.similarity_score is not None
116
+ print(
117
+ f" attribution: {row.similarity_score:.3f} "
118
+ f"({row.attribution_source})"
119
+ )
120
+ print(f" decision_branch: {row.decision_branch} ci_bucket: {row.ci_bucket}")
121
+ print(f" confidence: {_confidence_str(row)}")
122
+ print(
123
+ " policy_v3: "
124
+ f"{_float_str(row.policy_v3_confidence)} "
125
+ f"(delta={_delta_str(row.confidence_delta_from_policy_v3)})"
126
+ )
127
+ if row.ci_resolution is not None:
128
+ print(
129
+ " ci_resolution: "
130
+ f"verdict={row.ci_resolution.verdict} "
131
+ f"reliability={_float_str(row.ci_resolution.reliability)} "
132
+ f"suspected_flake={row.ci_resolution.suspected_flake}"
133
+ )
134
+ if row.ci_resolution_skips:
135
+ print(f" ci_skipped: {row.ci_resolution_skips}")
136
+ print(f" human_judgment: {row.human_judgment!r} (fill in by hand)")
137
+ print(f"{len(rows)} row(s)")
138
+
139
+
140
+ def _float_str(value: float | None) -> str:
141
+ if value is None:
142
+ return "-"
143
+ return f"{value:.3f}"
144
+
145
+
146
+ def _delta_str(value: float | int | None) -> str:
147
+ if value is None:
148
+ return "-"
149
+ if isinstance(value, int):
150
+ return f"{value:+d}"
151
+ return f"{value:+.3f}"
152
+
153
+
154
+ def _parse_values(raw: str) -> tuple[float, ...]:
155
+ values: list[float] = []
156
+ for part in raw.split(","):
157
+ token = part.strip()
158
+ if not token:
159
+ raise ValueError("--values must be a comma-separated list of floats")
160
+ values.append(float(token))
161
+ return tuple(values)
162
+
163
+
164
+ def _print_sensitivity_table(rows: list[LabelConfidenceSensitivityRow]) -> None:
165
+ if not rows:
166
+ print("no data")
167
+ return
168
+
169
+ current_stratum: tuple[str, int, str, str] | None = None
170
+ for row in rows:
171
+ stratum = (
172
+ row.recipe_id,
173
+ row.recipe_version,
174
+ row.eligibility_source or "ineligible",
175
+ row.knob,
176
+ )
177
+ if stratum != current_stratum:
178
+ current_stratum = stratum
179
+ print(
180
+ f"--- {row.recipe_id} v{row.recipe_version} "
181
+ f"eligibility_source={row.eligibility_source} {row.knob} "
182
+ f"affected_attributed_completions: {row.affected_attributed_completions}"
183
+ )
184
+ print(
185
+ " value sft_count delta sft_mean sft_median "
186
+ "all_mean all_median v3_all_mean delta_v3"
187
+ )
188
+ print(
189
+ f" {row.value:>5.3f} "
190
+ f"{row.sft_eligible_count:>9d} "
191
+ f"{_delta_str(row.sft_eligible_delta):>5} "
192
+ f"{_float_str(row.sft_confidences.mean):>8} "
193
+ f"{_float_str(row.sft_confidences.median):>10} "
194
+ f"{_float_str(row.all_confidences.mean):>8} "
195
+ f"{_float_str(row.all_confidences.median):>10} "
196
+ f"{_float_str(row.policy_v3_all_confidences.mean):>11} "
197
+ f"{_delta_str(row.all_mean_delta_from_policy_v3):>8}"
198
+ )
199
+ print(f"{len(rows)} sweep row(s)")
200
+
201
+
202
+ def _print_latency_report(report: DecisionLatencyReport) -> None:
203
+ if not report.buckets:
204
+ print("no data")
205
+ if any(report.skipped_decisions.values()):
206
+ print(f"skipped: {report.skipped_decisions}")
207
+ return
208
+
209
+ print(
210
+ f"latency_buckets: requested={report.buckets_requested} "
211
+ f"returned={report.buckets_returned}"
212
+ )
213
+ print(
214
+ f"decisions: included={report.included_decisions} "
215
+ f"total={report.total_decisions}"
216
+ )
217
+ print(f"skipped: {report.skipped_decisions}")
218
+ if report.accept_rate_delta is not None:
219
+ print(
220
+ f"accept_rate_delta_slowest_minus_fastest: {report.accept_rate_delta:.3f}"
221
+ )
222
+ if report.mean_confidence_delta is not None:
223
+ print(
224
+ "mean_confidence_delta_slowest_minus_fastest: "
225
+ f"{report.mean_confidence_delta:.3f}"
226
+ )
227
+ for bucket in report.buckets:
228
+ confidence = (
229
+ "None"
230
+ if bucket.mean_confidence is None
231
+ else f"{bucket.mean_confidence:.3f}"
232
+ )
233
+ print(
234
+ f"--- {bucket.bucket} "
235
+ f"{bucket.min_latency_ms:.0f}-{bucket.max_latency_ms:.0f} ms"
236
+ )
237
+ print(
238
+ f" decisions: {bucket.decisions} "
239
+ f"(accepts={bucket.accepts} rejects={bucket.rejects})"
240
+ )
241
+ print(f" accept_rate: {bucket.accept_rate:.3f}")
242
+ print(
243
+ f" mean_latency_ms: {bucket.mean_latency_ms:.1f} "
244
+ f"mean_confidence: {confidence} "
245
+ f"(n={bucket.confidence_count})"
246
+ )
247
+
248
+
249
+ def build_parser() -> argparse.ArgumentParser:
250
+ """The argv contract for this report, extracted so the generated
251
+ CLI reference can walk it."""
252
+ parser = argparse.ArgumentParser(
253
+ prog="label_confidence_inspection", description=__doc__.splitlines()[0]
254
+ )
255
+ parser.add_argument(
256
+ "--org",
257
+ default=os.environ.get("SEDIMENT_ORG_ID"),
258
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
259
+ help="org id (default: $SEDIMENT_ORG_ID)",
260
+ )
261
+ parser.add_argument(
262
+ "--n",
263
+ type=int,
264
+ default=None,
265
+ help=f"sample size, stratified across the confidence ladder's real "
266
+ f"branches (default: {_DEFAULT_N})",
267
+ )
268
+ parser.add_argument(
269
+ "--json", action="store_true", help="emit JSON rows instead of a table"
270
+ )
271
+ parser.add_argument(
272
+ "--recipe",
273
+ choices=("sft_curated", "sft_verified"),
274
+ default=None,
275
+ help="SFT evidence recipe (default: sft_curated)",
276
+ )
277
+ parser.add_argument(
278
+ "--sensitivity",
279
+ action="store_true",
280
+ help="sweep LabelConfidencePolicy knobs over all assembled attributed_completions",
281
+ )
282
+ parser.add_argument(
283
+ "--knob",
284
+ action="append",
285
+ choices=LABEL_CONFIDENCE_POLICY_KNOBS,
286
+ help="LabelConfidencePolicy knob to sweep in sensitivity mode; may repeat",
287
+ )
288
+ parser.add_argument(
289
+ "--values",
290
+ help="comma-separated grid for a single --knob in sensitivity mode",
291
+ )
292
+ parser.add_argument(
293
+ "--min-confidence",
294
+ type=float,
295
+ default=None,
296
+ help=f"SFT confidence floor for sensitivity mode "
297
+ f"(default: {_DEFAULT_MIN_CONFIDENCE})",
298
+ )
299
+ parser.add_argument(
300
+ "--latency-buckets",
301
+ type=int,
302
+ help="emit the decision-latency diagnostic using this many quantile "
303
+ "buckets instead of the label-confidence-inspection sample",
304
+ )
305
+ add_database_url_argument(parser)
306
+ parser.add_argument(
307
+ "--mirror-path",
308
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
309
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
310
+ f"{_DEFAULT_MIRROR_PATH!r})",
311
+ )
312
+ return parser
313
+
314
+
315
+ def main(argv: list[str] | None = None) -> int:
316
+ args = build_parser().parse_args(argv)
317
+
318
+ if args.sensitivity and args.latency_buckets is not None:
319
+ print(
320
+ "error: use --sensitivity or --latency-buckets, not both",
321
+ file=sys.stderr,
322
+ )
323
+ return 2
324
+ if args.knob and not args.sensitivity:
325
+ print("error: --knob requires --sensitivity", file=sys.stderr)
326
+ return 2
327
+ if args.min_confidence is not None and not args.sensitivity:
328
+ print("error: --min-confidence requires --sensitivity", file=sys.stderr)
329
+ return 2
330
+ if args.n is not None and args.sensitivity:
331
+ print("error: --n does not apply to --sensitivity", file=sys.stderr)
332
+ return 2
333
+ if args.n is not None and args.latency_buckets is not None:
334
+ print("error: --n does not apply to --latency-buckets", file=sys.stderr)
335
+ return 2
336
+ if args.recipe is not None and args.latency_buckets is not None:
337
+ print("error: --recipe does not apply to --latency-buckets", file=sys.stderr)
338
+ return 2
339
+ n = args.n if args.n is not None else _DEFAULT_N
340
+ recipe = args.recipe if args.recipe is not None else "sft_curated"
341
+ min_confidence = (
342
+ args.min_confidence
343
+ if args.min_confidence is not None
344
+ else _DEFAULT_MIN_CONFIDENCE
345
+ )
346
+ if n <= 0:
347
+ print("error: --n must be a positive integer", file=sys.stderr)
348
+ return 2
349
+ if args.values and not args.sensitivity:
350
+ print("error: --values requires --sensitivity", file=sys.stderr)
351
+ return 2
352
+ if args.values and (not args.knob or len(args.knob) != 1):
353
+ print("error: --values requires exactly one --knob", file=sys.stderr)
354
+ return 2
355
+ if not 0.0 <= min_confidence <= 1.0:
356
+ print("error: --min-confidence must be in [0.0, 1.0]", file=sys.stderr)
357
+ return 2
358
+ if args.latency_buckets is not None and args.latency_buckets <= 0:
359
+ print("error: --latency-buckets must be a positive integer", file=sys.stderr)
360
+ return 2
361
+
362
+ try:
363
+ org_id = normalize_org_id(args.org)
364
+ except ValueError as exc:
365
+ print(f"error: {exc}", file=sys.stderr)
366
+ return 2
367
+
368
+ with one_shot_fact_store(
369
+ args.database_url, operation="inspect label confidence"
370
+ ) as store:
371
+ mirrors = MirrorManager(args.mirror_path)
372
+ sft_policy = SFTPolicy(
373
+ recipe_id=recipe,
374
+ min_confidence=min_confidence,
375
+ )
376
+ if args.sensitivity:
377
+ if args.knob:
378
+ try:
379
+ values = _parse_values(args.values) if args.values else None
380
+ except ValueError as exc:
381
+ print(f"error: {exc}", file=sys.stderr)
382
+ return 2
383
+ grids = {
384
+ knob: values if values is not None else DEFAULT_SWEEP_GRIDS[knob]
385
+ for knob in args.knob
386
+ }
387
+ else:
388
+ grids = DEFAULT_SWEEP_GRIDS
389
+ rows = generate_label_confidence_sensitivity(
390
+ store,
391
+ mirrors,
392
+ org_id,
393
+ sft_min_confidence=min_confidence,
394
+ sft_policy=sft_policy,
395
+ grids=grids,
396
+ )
397
+ latency_report = None
398
+ elif args.latency_buckets is not None:
399
+ latency_report = generate_decision_latency_report(
400
+ store,
401
+ mirrors,
402
+ org_id,
403
+ latency_buckets=args.latency_buckets,
404
+ )
405
+ rows = None
406
+ else:
407
+ rows = generate_label_confidence_inspection(
408
+ store,
409
+ mirrors,
410
+ org_id,
411
+ n=n,
412
+ sft_policy=sft_policy,
413
+ )
414
+ latency_report = None
415
+
416
+ if args.json:
417
+ if latency_report is not None:
418
+ print(json.dumps(asdict(latency_report), indent=2))
419
+ else:
420
+ print(json.dumps([asdict(r) for r in rows or []], indent=2))
421
+ else:
422
+ if latency_report is not None:
423
+ print(f"org: {org_id} latency buckets: {args.latency_buckets}")
424
+ _print_latency_report(latency_report)
425
+ elif args.sensitivity:
426
+ print(
427
+ f"org: {org_id} sensitivity sweep "
428
+ f"sft min_confidence: {min_confidence:.3f}"
429
+ )
430
+ _print_sensitivity_table(rows)
431
+ else:
432
+ print(f"org: {org_id} sample size: {n}")
433
+ _print_table(rows or [])
434
+ return 0
435
+
436
+
437
+ if __name__ == "__main__":
438
+ raise SystemExit(main())
@@ -0,0 +1,73 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """JSON-only accepted-work lifecycle report."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+
10
+ from sediment_core import normalize_org_id
11
+ from sediment_derive import MirrorManager
12
+
13
+ from ..database import add_database_url_argument, one_shot_fact_store
14
+ from ..services.operational_reports import (
15
+ LifecycleReportRequest,
16
+ generate_lifecycle_report,
17
+ )
18
+
19
+ _DEFAULT_MIRROR_PATH = "./mirrors"
20
+
21
+
22
+ def build_parser() -> argparse.ArgumentParser:
23
+ parser = argparse.ArgumentParser(
24
+ prog="lifecycle_report",
25
+ description="Report accepted-work progression and operational evidence.",
26
+ )
27
+ parser.add_argument(
28
+ "--org",
29
+ default=os.environ.get("SEDIMENT_ORG_ID"),
30
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
31
+ help="org id (default: $SEDIMENT_ORG_ID)",
32
+ )
33
+ parser.add_argument(
34
+ "--json",
35
+ action="store_true",
36
+ required=True,
37
+ help="emit the canonical JSON artifact (required)",
38
+ )
39
+ add_database_url_argument(parser)
40
+ parser.add_argument(
41
+ "--mirror-path",
42
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
43
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
44
+ f"{_DEFAULT_MIRROR_PATH!r})",
45
+ )
46
+ return parser
47
+
48
+
49
+ def main(argv: list[str] | None = None) -> int:
50
+ args = build_parser().parse_args(argv)
51
+ try:
52
+ org_id = normalize_org_id(args.org)
53
+ except ValueError as exc:
54
+ print(f"error: {exc}", file=sys.stderr)
55
+ return 2
56
+ try:
57
+ with one_shot_fact_store(
58
+ args.database_url, operation="generate accepted-work lifecycle report"
59
+ ) as store:
60
+ envelope = generate_lifecycle_report(
61
+ store,
62
+ MirrorManager(args.mirror_path),
63
+ LifecycleReportRequest(org_id),
64
+ )
65
+ except Exception as exc:
66
+ print(f"error: couldn't generate lifecycle report: {exc}", file=sys.stderr)
67
+ return 1
68
+ print(envelope.to_json())
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,165 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Pull request merge-retention diagnostic report.
3
+
4
+ sediment report merge-retention --org acme-corp
5
+ sediment report merge-retention --org acme-corp --json
6
+ sediment report merge-retention --org acme-corp --rows-out review.jsonl
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ from dataclasses import asdict
16
+
17
+ from sediment_core import normalize_org_id
18
+ from sediment_derive import MirrorManager
19
+ from sediment_export import (
20
+ MergeRetentionReport,
21
+ RetentionScoreSummary,
22
+ generate_merge_retention_report_result,
23
+ merge_retention_to_export_rows,
24
+ write_jsonl,
25
+ )
26
+
27
+ from ..database import add_database_url_argument, one_shot_fact_store
28
+
29
+ _DEFAULT_MIRROR_PATH = "./mirrors"
30
+
31
+
32
+ def _format_counts(counts: dict[str, int]) -> str:
33
+ return (
34
+ ", ".join(f"{key}={value}" for key, value in sorted(counts.items())) or "none"
35
+ )
36
+
37
+
38
+ def _print_score(name: str, summary: RetentionScoreSummary) -> None:
39
+ distribution = summary.distribution
40
+ assert distribution.mean is not None
41
+ assert distribution.median is not None
42
+ assert distribution.p10 is not None
43
+ assert distribution.p90 is not None
44
+ print(
45
+ f"{name}: n={distribution.count} mean={distribution.mean:.3f} "
46
+ f"median={distribution.median:.3f} p10={distribution.p10:.3f} "
47
+ f"p90={distribution.p90:.3f}"
48
+ )
49
+ for threshold in summary.thresholds:
50
+ rate = "no data" if threshold.rate is None else f"{threshold.rate:.3f}"
51
+ print(
52
+ f" below {threshold.threshold:.1f}: {threshold.below}/"
53
+ f"{threshold.total} ({rate})"
54
+ )
55
+
56
+
57
+ def _print_table(report: MergeRetentionReport) -> None:
58
+ print(f"attributed file candidates: {report.attributed_file_candidates}")
59
+ print(f"candidates joined to merge: {report.candidates_joined_to_merge}")
60
+ print(f"scored rows: {report.scored_rows}")
61
+ print(f"pull requests with joined candidates: {report.joined_pull_requests}")
62
+ print(f"pull requests with scored rows: {report.scored_pull_requests}")
63
+ print(f"membership: {_format_counts(report.membership)}")
64
+ print(f"scoring skips: {_format_counts(report.scoring_skips)}")
65
+ for unit, counts in sorted(report.repository_skipped.items()):
66
+ print(f"repository skips ({unit}): {_format_counts(counts)}")
67
+ print(f"attribution skips: {_format_counts(report.attribution_skips)}")
68
+ print(
69
+ f"decision attachment skips: {_format_counts(report.decision_attachment_skips)}"
70
+ )
71
+ if report.scored_rows == 0:
72
+ print("scores: no data")
73
+ else:
74
+ _print_score("final head", report.head)
75
+ _print_score("merged commit", report.merge)
76
+ print(f"explicit accepted rows: {report.explicit_accept_rows}")
77
+ for name, thresholds in (
78
+ ("explicit final head", report.explicit_accept_head_thresholds),
79
+ ("explicit merged commit", report.explicit_accept_merge_thresholds),
80
+ ):
81
+ for threshold in thresholds:
82
+ rate = "no data" if threshold.rate is None else f"{threshold.rate:.3f}"
83
+ print(
84
+ f"{name} below {threshold.threshold:.1f}: {threshold.below}/"
85
+ f"{threshold.total} ({rate})"
86
+ )
87
+ print(f"attribution provenance: {report.attribution_provenance}")
88
+ print(f"merge retention provenance: {report.merge_retention_provenance}")
89
+
90
+
91
+ def build_parser() -> argparse.ArgumentParser:
92
+ """Return the argv contract consumed by the generated CLI reference."""
93
+ parser = argparse.ArgumentParser(
94
+ prog="merge_retention_report",
95
+ description="Measure attributed file changes at pull request merge boundaries.",
96
+ )
97
+ parser.add_argument(
98
+ "--org",
99
+ default=os.environ.get("SEDIMENT_ORG_ID"),
100
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
101
+ help="org id (default: $SEDIMENT_ORG_ID)",
102
+ )
103
+ parser.add_argument(
104
+ "--json", action="store_true", help="emit JSON instead of a text report"
105
+ )
106
+ parser.add_argument(
107
+ "--rows-out",
108
+ metavar="FILE",
109
+ help="write canonical merge-retention rows to FILE as JSONL",
110
+ )
111
+ add_database_url_argument(parser)
112
+ parser.add_argument(
113
+ "--mirror-path",
114
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
115
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
116
+ f"{_DEFAULT_MIRROR_PATH!r})",
117
+ )
118
+ return parser
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ args = build_parser().parse_args(argv)
123
+ try:
124
+ org_id = normalize_org_id(args.org)
125
+ except ValueError as exc:
126
+ print(f"error: {exc}", file=sys.stderr)
127
+ return 2
128
+ with one_shot_fact_store(
129
+ args.database_url, operation="generate merge-retention report"
130
+ ) as store:
131
+ result = generate_merge_retention_report_result(
132
+ store,
133
+ MirrorManager(args.mirror_path),
134
+ org_id,
135
+ )
136
+ report = result.report
137
+ if args.json:
138
+ print(json.dumps(asdict(report), indent=2))
139
+ else:
140
+ _print_table(report)
141
+ if args.rows_out:
142
+ try:
143
+ write_result = write_jsonl(
144
+ merge_retention_to_export_rows(result.rows),
145
+ args.rows_out,
146
+ split_enabled=False,
147
+ )
148
+ except (OSError, TypeError, ValueError) as exc:
149
+ print(f"error: couldn't write merge-retention rows: {exc}", file=sys.stderr)
150
+ return 1
151
+ if write_result.written:
152
+ print(
153
+ f"wrote {len(result.rows)} merge-retention rows to {args.rows_out}",
154
+ file=sys.stderr,
155
+ )
156
+ else:
157
+ print(
158
+ f"wrote 0 merge-retention rows; left {args.rows_out} unchanged",
159
+ file=sys.stderr,
160
+ )
161
+ return 0
162
+
163
+
164
+ if __name__ == "__main__":
165
+ raise SystemExit(main())