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,1534 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ """Per-model outcome report — attribution, CI, and acceptance by model.
3
+
4
+ Prints ``sediment_export.generate_model_report`` for one org: an aligned
5
+ table on stdout by default, or ``--json`` for machines. An empty result (no
6
+ data for the org/window) is a valid answer — it prints "no data" and exits 0,
7
+ not an error.
8
+
9
+ sediment report model --org acme-corp
10
+ sediment report model --org acme-corp --since-days 30
11
+ sediment report model --org acme-corp --json
12
+ sediment report model --org acme-corp --trend
13
+ sediment report model --org acme-corp \
14
+ --compare claude-sonnet-5 claude-opus-4-8
15
+ sediment report model --org acme-corp \
16
+ --compare-all claude-sonnet-5 claude-opus-4-8 gpt-5-codex --regret
17
+
18
+ ``--compare MODEL_A MODEL_B`` adds a two-proportion z-test and power
19
+ analysis on CI pass rate and attribution rate between the two named models, over
20
+ the same ``--org``/``--since-days`` window as the descriptive table — Cohen's
21
+ h effect size, 95% confidence interval, z-statistic, raw p-value,
22
+ Bonferroni-corrected significance, and current minimum detectable effect,
23
+ plus a separate Bayesian Beta-Binomial posterior companion reporting
24
+ ``P(A>B)`` and a credible interval for the rate difference. Both are
25
+ stdlib-only (``sediment_export.significance``, no scipy). See
26
+ ``docs/agents/statistics.md`` for the honest
27
+ caveats around treating a p-value as more than it is, especially the warning
28
+ against repeated peeking as data accumulates. ``--peek-fraction`` adds a
29
+ diagnostic O'Brien-Fleming sequential boundary read alongside the naive
30
+ fixed-sample call. ``--effect-decay``, available only with ``--compare``,
31
+ adds Cohen's h checkpoint tables at 25/50/75/100% prefixes to flag early
32
+ effects that shrink as more outcomes arrive.
33
+
34
+ ``--bootstrap-check``, with ``--compare``, adds a fixed-seed
35
+ nonparametric bootstrap percentile CI beside the analytic CI for the same
36
+ counts and flags substantial disagreement. It is a reproducible diagnostic,
37
+ not persisted state.
38
+
39
+ ``--compare-all MODEL1 MODEL2 MODEL3 ...`` runs that same
40
+ comparison for every unordered model pair and applies Benjamini-Hochberg FDR
41
+ control across the full family of p-values: both metrics times every pair.
42
+ ``--regret`` adds the expected regret, in raw proportion points, of shipping
43
+ the apparent-best observed model if a compared alternative is actually better.
44
+
45
+ Storage is read the same way the operator CLI reads it — ``--database-url``/
46
+ ``SEDIMENT_DATABASE_URL`` and ``--mirror-path``/``SEDIMENT_MIRROR_PATH`` — but this
47
+ script does NOT import ``sediment_api.config.settings``: that model requires
48
+ ``SEDIMENT_ORG_ID`` (and, outside dev mode, real auth secrets) just to
49
+ construct, which would defeat the point of this script's own ``--org`` flag
50
+ (report any org a deployment holds, without touching API auth config). A
51
+ missing ``--mirror-path`` is not fatal: ``MirrorManager`` degrades to "no
52
+ mirror for this repo" per-repo (an unmirrored repo's commits are skipped,
53
+ never guessed at, same as ``assemble_attributed_completions`` — see ``attributed_completions.py``), so an
54
+ operator who only wants git-notes-derived attributed completions never needs one.
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import argparse
60
+ import json
61
+ import os
62
+ import sys
63
+ from dataclasses import asdict
64
+ from datetime import UTC, datetime
65
+
66
+ from sediment_core import CIResult, normalize_org_id
67
+ from sediment_derive import (
68
+ AttributionShareAlert,
69
+ RepoAttributionShare,
70
+ Provenance,
71
+ MirrorManager,
72
+ InferenceCall,
73
+ inference_fact_id,
74
+ inference_model,
75
+ inference_observed_at,
76
+ derive_fate_result,
77
+ four_gram_containment,
78
+ RepositoryContext,
79
+ read_repository_context,
80
+ )
81
+ from sediment_export import (
82
+ DEFAULT_BOOTSTRAP_ITERATIONS,
83
+ DEFAULT_BOOTSTRAP_SEED,
84
+ AbandonmentSummary,
85
+ BayesianProportionComparison,
86
+ BootstrapIntervalDiagnostic,
87
+ CIGrain,
88
+ EffectDecayReport,
89
+ ModelComparison,
90
+ ModelOutcomeReport,
91
+ ModelOutcomeReportResult,
92
+ ModelTemporalTrend,
93
+ MultiModelComparison,
94
+ NonInferiorityResult,
95
+ OutcomeReportPolicy,
96
+ OperationalReportScope,
97
+ ProportionComparison,
98
+ RegretReport,
99
+ SignalFunnelReport,
100
+ StratificationCheck,
101
+ StratificationStatus,
102
+ TrendStatus,
103
+ AttributedCompletion,
104
+ assemble_attributed_completions_result,
105
+ bootstrap_two_proportion_interval_diagnostic,
106
+ build_abandonment_summary,
107
+ build_model_report_result,
108
+ compare_all_models,
109
+ compare_models,
110
+ effect_decay_check,
111
+ expected_regret,
112
+ derive_model_report_attribution_share,
113
+ non_inferiority_test,
114
+ required_days_for_mde,
115
+ required_n_per_arm_for_mde,
116
+ )
117
+
118
+ from sediment_export.outcome_report import (
119
+ model_report_inputs,
120
+ model_ci_trials,
121
+ prepare_report_ci,
122
+ with_model_assembly_diagnostics,
123
+ )
124
+
125
+ from .attribution_share_report import format_repository_label
126
+
127
+ from ..database import add_database_url_argument, one_shot_fact_store
128
+ from ..services.operational_reports import (
129
+ base_model_report_payload,
130
+ generate_operational_model_report,
131
+ )
132
+
133
+
134
+ _DEFAULT_MIRROR_PATH = "./mirrors"
135
+ _DEFAULT_ALPHA = 0.05
136
+ _DEFAULT_POWER = 0.80
137
+
138
+
139
+ def _format_breakdown(counts: dict[str, int]) -> str:
140
+ return ", ".join(f"{key}={value}" for key, value in counts.items()) or "-"
141
+
142
+
143
+ def _format_rate_ci(rate: float, ci: tuple[float, float]) -> str:
144
+ lower, upper = ci
145
+ return f"{rate:.1%} [{lower:.1%}, {upper:.1%}]"
146
+
147
+
148
+ def _print_table(rows: list[ModelOutcomeReport]) -> None:
149
+ if not rows:
150
+ print("no data")
151
+ return
152
+ header = (
153
+ f"{'model':<28} {'completions':>11} {'attributed':>10} {'attribution_rate':>23} "
154
+ f"{'ci_linked':>9} {'ci_passed':>9} {'ci_pass':>23} {'accepts':>8} "
155
+ f"{'rejects':>8} {'mean_sim':>9}"
156
+ )
157
+ print(header)
158
+ for r in rows:
159
+ print(
160
+ f"{r.model:<28} {r.completions:>11} {r.attributed_inference_calls:>10} "
161
+ f"{_format_rate_ci(r.attribution_rate, r.attribution_rate_ci):>23} "
162
+ f"{r.ci_linked:>9} {r.ci_passed:>9} "
163
+ f"{_format_rate_ci(r.ci_pass_rate, r.ci_pass_rate_ci):>23} "
164
+ f"{r.explicit_accepts:>8} "
165
+ f"{r.explicit_rejects:>8} {r.mean_similarity:>9.3f}"
166
+ )
167
+ print(
168
+ f"{'':<28} {'session_commit_unobserved':>31}: {r.session_commit_unobserved}"
169
+ )
170
+ print(
171
+ f"{'':<28} {'ci_failures_by_workflow':>31}: "
172
+ f"{_format_breakdown(r.ci_failures_by_workflow)}"
173
+ )
174
+ print(
175
+ f"{'':<28} {'explicit_rejects_by_agent_harness':>31}: "
176
+ f"{_format_breakdown(r.explicit_rejects_by_agent_harness)}"
177
+ )
178
+ print(f"{'':<28} {'fates':>31}: {_format_breakdown(r.fates)}")
179
+ print(
180
+ f"{'':<28} {'human-explicit accept fates':>31}: "
181
+ f"{_format_breakdown(r.explicit_accept_fates)}"
182
+ )
183
+ print(
184
+ f"{'':<28} {'fates with external changes':>31}: "
185
+ f"{_format_breakdown(r.fates_with_external_changes)}"
186
+ )
187
+
188
+
189
+ def _print_fate_diagnostics(result: ModelOutcomeReportResult) -> None:
190
+ print()
191
+ print("Fate diagnostic")
192
+ print(f"derivation_skipped: {_format_breakdown(result.fate_skipped)}")
193
+ print(f"provenance: {result.fate_provenance or '-'}")
194
+
195
+
196
+ def _print_attribution_share(
197
+ rows: list[RepoAttributionShare], alerts: list[AttributionShareAlert]
198
+ ) -> None:
199
+ print("attribution share")
200
+ total = sum(row.agent_plausible_commits for row in rows)
201
+ if total == 0:
202
+ print("no agent-plausible commits in this window")
203
+ return
204
+
205
+ git_notes = sum(row.git_notes_attributed for row in rows)
206
+ share = git_notes / total
207
+ print(
208
+ f"{share:.1%} of agent-plausible commits in this window carry a "
209
+ "session stamp. "
210
+ f"Token-overlap matches among the remaining {1 - share:.1%} carry a "
211
+ "similarity discount in downstream confidence."
212
+ )
213
+ for row in rows:
214
+ print(
215
+ f" {format_repository_label(row.repo, row.repository_identity)}: {row.git_notes_share:.1%} session-stamped "
216
+ f"({row.git_notes_attributed}/{row.agent_plausible_commits}); "
217
+ f"token_overlap={row.jaccard_attributed} "
218
+ f"unattributed={row.unattributed}"
219
+ )
220
+ for alert in alerts:
221
+ print(
222
+ f" alert {format_repository_label(alert.repo, alert.repository_identity)}: {alert.kind.value} -- {alert.reason}"
223
+ )
224
+
225
+
226
+ def _print_signal_funnel(rows: list[SignalFunnelReport]) -> None:
227
+ print()
228
+ print("signal funnel")
229
+ if not rows:
230
+ print("no data")
231
+ return
232
+ header = (
233
+ f"{'model':<28} {'completions':>11} {'attributed':>19} "
234
+ f"{'ci_linked':>19} {'has_decision':>19} {'training_eligible':>21}"
235
+ )
236
+ print(header)
237
+ for row in rows:
238
+ print(
239
+ f"{row.model:<28} {row.completions_total:>11} "
240
+ f"{_fmt_rate(row.attributed, row.completions_total, row.attribution_rate):>19} "
241
+ f"{_fmt_rate(row.ci_linked, row.attributed, row.ci_linked_retention_rate):>19} "
242
+ f"{_fmt_rate(row.has_decision, row.attributed, row.has_decision_retention_rate):>19} "
243
+ f"{_fmt_rate(row.training_row_eligible, row.attributed, row.training_row_eligible_retention_rate):>21}"
244
+ )
245
+
246
+
247
+ def _print_abandonment(summary: AbandonmentSummary) -> None:
248
+ print()
249
+ print("abandonment")
250
+ print(f"abandoned_sessions: {summary.abandoned_sessions}")
251
+ print(f"grade_eligible_sessions: {summary.grade_eligible_sessions}")
252
+ print(f"implicit_only_sessions: {summary.implicit_only_sessions}")
253
+ print(f"negative_completions: {summary.negative_completions}")
254
+ print(f"explicit_accepts_unjoined: {summary.explicit_accepts_unjoined}")
255
+ print(f"derivation_skipped: {_format_breakdown(summary.derivation_skipped)}")
256
+ print(f"provenance: {summary.provenance or '-'}")
257
+
258
+
259
+ def _row_for_model(
260
+ rows: list[ModelOutcomeReport], model: str, since_days: int | None
261
+ ) -> ModelOutcomeReport:
262
+ """The named model's row, or an all-zero row if it has no data in this
263
+ org/window. Mirrors the report's own "no data is a valid answer, not an
264
+ error" convention (see the module docstring): a typo'd or silent model
265
+ name does not crash ``--compare``, it flows into ``compare_models`` as
266
+ ``n=0`` on every metric, which ``two_proportion_z_test`` already turns
267
+ into an explicit ``insufficient_data=True`` rather than a fabricated
268
+ comparison.
269
+ """
270
+ for r in rows:
271
+ if r.model == model:
272
+ return r
273
+ provenance = (
274
+ rows[0].provenance
275
+ if rows
276
+ else Provenance(
277
+ policy_version=OutcomeReportPolicy().policy_version,
278
+ quarantine_revision=0,
279
+ )
280
+ )
281
+ grain = rows[0].grain if rows else CIGrain.COMMIT
282
+ return ModelOutcomeReport(
283
+ model=model,
284
+ since_days=since_days,
285
+ completions=0,
286
+ attributed_inference_calls=0,
287
+ attribution_rate=0.0,
288
+ attribution_rate_ci=(0.0, 0.0),
289
+ ci_linked=0,
290
+ ci_passed=0,
291
+ ci_pass_rate=0.0,
292
+ ci_pass_rate_ci=(0.0, 0.0),
293
+ explicit_accepts=0,
294
+ explicit_rejects=0,
295
+ mean_similarity=0.0,
296
+ provenance=provenance,
297
+ grain=grain,
298
+ )
299
+
300
+
301
+ def _print_proportion_row(
302
+ name: str, pc: ProportionComparison, show_sequential: bool = False
303
+ ) -> None:
304
+ if pc.insufficient_data:
305
+ placeholder = (
306
+ f"{name:<14} {'--':>8} {'--':>8} {'--':>8} {'--':>7} "
307
+ f"{'--':>8} {'--':>8} {'--':>7} {'--':>9} {'--':>10}"
308
+ )
309
+ if show_sequential:
310
+ placeholder += f" {'--':>8} {'--':>9} {'--':>9}"
311
+ print(
312
+ f"{placeholder} {pc.n_a:>6} {pc.n_b:>6} "
313
+ "insufficient data (n=0 in at least one group)"
314
+ )
315
+ return
316
+ note = "small n, less reliable" if pc.small_n else ""
317
+ if pc.significant_after_bonferroni:
318
+ sig = "corrected"
319
+ elif pc.significant_at_alpha:
320
+ sig = "raw_only"
321
+ else:
322
+ sig = "no"
323
+ sequential = pc.sequential_boundary
324
+ if sequential is None:
325
+ seq_columns = ""
326
+ else:
327
+ seq_sig = (
328
+ "yes" if sequential.significant_after_sequential_boundary else "not_yet"
329
+ )
330
+ seq_columns = (
331
+ f"{sequential.critical_z:>8.2f} {sequential.alpha_spent:>9.4f} "
332
+ f"{seq_sig:>9} "
333
+ )
334
+ print(
335
+ f"{name:<14} {pc.proportion_a:>8.1%} {pc.proportion_b:>8.1%} "
336
+ f"{pc.difference:>+8.1%} {pc.cohens_h:>+7.3f} "
337
+ f"{pc.ci_low:>+8.1%} {pc.ci_high:>+8.1%} "
338
+ f"{pc.z_stat:>7.2f} {pc.p_value:>9.4f} {sig:>10} "
339
+ f"{seq_columns}{pc.n_a:>6} {pc.n_b:>6} {note}"
340
+ )
341
+
342
+
343
+ def _format_percentage_points(value: float) -> str:
344
+ return f"{value * 100:.1f} percentage points"
345
+
346
+
347
+ def _target_n_per_arm(pc: ProportionComparison, target_mde: float | None) -> int | None:
348
+ if target_mde is None or pc.insufficient_data or pc.degenerate_se:
349
+ return None
350
+ return required_n_per_arm_for_mde(target_mde, pc.baseline_p)
351
+
352
+
353
+ def _target_days(
354
+ pc: ProportionComparison,
355
+ target_mde: float | None,
356
+ completions_per_day: tuple[float, float] | None,
357
+ ) -> float | None:
358
+ if (
359
+ target_mde is None
360
+ or pc.insufficient_data
361
+ or pc.degenerate_se
362
+ or completions_per_day is None
363
+ ):
364
+ return None
365
+ completions_per_day_a, completions_per_day_b = completions_per_day
366
+ return required_days_for_mde(
367
+ target_mde,
368
+ pc.baseline_p,
369
+ completions_per_day_a,
370
+ completions_per_day_b,
371
+ )
372
+
373
+
374
+ def _print_power_row(
375
+ name: str,
376
+ pc: ProportionComparison,
377
+ target_mde: float | None,
378
+ completions_per_day: tuple[float, float] | None,
379
+ ) -> None:
380
+ if pc.insufficient_data:
381
+ print(f"{name}: MDE unavailable because at least one group has n=0.")
382
+ return
383
+ if pc.degenerate_se:
384
+ print(
385
+ f"{name}: MDE unavailable because both arms shared a boundary "
386
+ "outcome (all-pass or all-fail); the normal-approximation power "
387
+ "formula is undefined at a pooled rate of 0 or 1."
388
+ )
389
+ return
390
+ print(
391
+ f"{name}: at your current sample size (n_a={pc.n_a}, n_b={pc.n_b}), "
392
+ "this comparison can detect a difference of at least "
393
+ f"{_format_percentage_points(pc.minimum_detectable_effect)} "
394
+ f"with {_DEFAULT_POWER:.0%} power."
395
+ )
396
+ target_n = _target_n_per_arm(pc, target_mde)
397
+ if target_n is not None:
398
+ print(
399
+ f"{name}: to detect a target difference of "
400
+ f"{_format_percentage_points(target_mde)} with {_DEFAULT_POWER:.0%} "
401
+ f"power, plan for n={target_n} per arm."
402
+ )
403
+ target_days = _target_days(pc, target_mde, completions_per_day)
404
+ if target_days is not None:
405
+ completions_per_day_a, completions_per_day_b = completions_per_day
406
+ print(
407
+ f"{name}: at {completions_per_day_a:g}/{completions_per_day_b:g} "
408
+ "completions/day for model A/model B, estimated duration is "
409
+ f"{target_days:.2f} days."
410
+ )
411
+
412
+
413
+ def _print_bayesian_row(name: str, comparison: BayesianProportionComparison) -> None:
414
+ if comparison.insufficient_data:
415
+ print(f"{name:<14} insufficient data (n=0 in at least one group)")
416
+ return
417
+ note = "small n, posterior is prior-sensitive" if comparison.small_n else ""
418
+ print(
419
+ f"{name:<14} {comparison.posterior_mean_a:>8.1%} "
420
+ f"{comparison.posterior_mean_b:>8.1%} "
421
+ f"{comparison.mean_difference:>+9.1%} "
422
+ f"{comparison.credible_interval_low:>+9.1%} "
423
+ f"{comparison.credible_interval_high:>+9.1%} "
424
+ f"{comparison.probability_a_gt_b:>8.1%} "
425
+ f"{comparison.n_a:>6} {comparison.n_b:>6} {note}"
426
+ )
427
+
428
+
429
+ def _print_bayesian_comparison(comparison: ModelComparison) -> None:
430
+ bayes = comparison.bayesian_ci_pass_rate
431
+ print()
432
+ print("bayesian posterior")
433
+ print(
434
+ "method: Beta-Binomial posterior with flat "
435
+ f"Beta({bayes.prior_alpha:g}, {bayes.prior_beta:g}) prior; "
436
+ f"Monte Carlo draws={bayes.sample_count}, seed={bayes.seed}. "
437
+ "P(A>B) is posterior probability, not a p-value."
438
+ )
439
+ header = (
440
+ f"{'metric':<14} {'post_a':>8} {'post_b':>8} {'mean_diff':>9} "
441
+ f"{'cred_low':>9} {'cred_high':>9} {'P(A>B)':>8} "
442
+ f"{'n_a':>6} {'n_b':>6}"
443
+ )
444
+ print(header)
445
+ _print_bayesian_row("ci_pass_rate", comparison.bayesian_ci_pass_rate)
446
+ _print_bayesian_row("attribution_rate", comparison.bayesian_attribution_rate)
447
+
448
+
449
+ def _non_inferiority_results(
450
+ report_a: ModelOutcomeReport, report_b: ModelOutcomeReport, margin: float
451
+ ) -> dict[str, NonInferiorityResult]:
452
+ return {
453
+ "ci_pass_rate": non_inferiority_test(
454
+ report_a.ci_passed,
455
+ report_a.ci_linked,
456
+ report_b.ci_passed,
457
+ report_b.ci_linked,
458
+ margin,
459
+ ),
460
+ "attribution_rate": non_inferiority_test(
461
+ report_a.attributed_inference_calls,
462
+ report_a.completions,
463
+ report_b.attributed_inference_calls,
464
+ report_b.completions,
465
+ margin,
466
+ ),
467
+ }
468
+
469
+
470
+ def _print_non_inferiority_row(name: str, result: NonInferiorityResult) -> None:
471
+ if result.insufficient_data:
472
+ print(f"{name:<14} insufficient data (n=0 in at least one group)")
473
+ return
474
+ note = "small n, less reliable" if result.small_n else ""
475
+ print(
476
+ f"{name:<14} {result.proportion_a:>8.1%} {result.proportion_b:>8.1%} "
477
+ f"{result.point_estimate:>+8.1%} {result.ci_lower_bound:>+11.1%} "
478
+ f"{-result.margin:>+10.1%} {str(result.is_non_inferior):>14} "
479
+ f"{result.n_a:>6} {result.n_b:>6} {note}"
480
+ )
481
+
482
+
483
+ def _print_non_inferiority(
484
+ comparison: ModelComparison,
485
+ results: dict[str, NonInferiorityResult],
486
+ ) -> None:
487
+ example = next(iter(results.values()))
488
+ print()
489
+ print(
490
+ "non-inferiority: "
491
+ f"baseline={comparison.model_a}; candidate={comparison.model_b}; "
492
+ f"margin={_format_percentage_points(example.margin)}; "
493
+ f"one-sided alpha={example.alpha:.2f}"
494
+ )
495
+ print(
496
+ "convention: estimate is candidate minus baseline (p_b - p_a); "
497
+ f"lower bound uses the equivalent two-sided "
498
+ f"{example.equivalent_two_sided_confidence:.0%} CI; "
499
+ "non-inferior means lower_bound > -margin."
500
+ )
501
+ header = (
502
+ f"{'metric':<14} {'prop_a':>8} {'prop_b':>8} {'b_minus_a':>8} "
503
+ f"{'lower':>11} {'threshold':>10} {'non_inferior':>14} "
504
+ f"{'n_a':>6} {'n_b':>6}"
505
+ )
506
+ print(header)
507
+ _print_non_inferiority_row("ci_pass_rate", results["ci_pass_rate"])
508
+ _print_non_inferiority_row("attribution_rate", results["attribution_rate"])
509
+
510
+
511
+ def _print_effect_decay_row(report: EffectDecayReport) -> None:
512
+ flag = "yes" if report.decay_detected else "no"
513
+ trend = report.decay_trend
514
+ trend_p = "n/a" if trend.p_value is None else f"{trend.p_value:.4f}"
515
+ print(
516
+ "decay_detected="
517
+ f"{flag} trend={trend.status} p_value={trend_p} "
518
+ f"drop={report.total_abs_drop:.3f} small_n={report.small_n}"
519
+ )
520
+ print(
521
+ f"{'checkpoint':>10} {'n_a':>6} {'n_b':>6} {'prop_a':>8} "
522
+ f"{'prop_b':>8} {'h':>8} {'|h|':>8} {'p_value':>9}"
523
+ )
524
+ for checkpoint in report.checkpoints:
525
+ comparison = checkpoint.comparison
526
+ if comparison.insufficient_data:
527
+ print(
528
+ f"{checkpoint.fraction:>9.0%} {checkpoint.n_a:>6} "
529
+ f"{checkpoint.n_b:>6} insufficient data"
530
+ )
531
+ continue
532
+ print(
533
+ f"{checkpoint.fraction:>9.0%} {checkpoint.n_a:>6} "
534
+ f"{checkpoint.n_b:>6} {checkpoint.proportion_a:>8.1%} "
535
+ f"{checkpoint.proportion_b:>8.1%} {checkpoint.cohens_h:>+8.3f} "
536
+ f"{checkpoint.abs_cohens_h:>8.3f} {comparison.p_value:>9.4f}"
537
+ )
538
+
539
+
540
+ def _print_effect_decay(
541
+ reports: dict[str, EffectDecayReport],
542
+ ) -> None:
543
+ print()
544
+ print(
545
+ "effect decay diagnostic: Cohen's h over accumulating prefixes (display only)"
546
+ )
547
+ print(
548
+ "flag rule: Mann-Kendall trend test over disjoint sequential blocks "
549
+ "of the same outcomes (trend=significant_decrease) -- the "
550
+ "checkpoints above are nested prefixes and not what drives the flag."
551
+ )
552
+ for metric, report in reports.items():
553
+ print()
554
+ print(metric)
555
+ _print_effect_decay_row(report)
556
+
557
+
558
+ def _bh_threshold_for_display(comparison: MultiModelComparison) -> float:
559
+ for pair in comparison.comparisons:
560
+ for pc in (pair.ci_pass_rate, pair.attribution_rate):
561
+ if not pc.insufficient_data:
562
+ return pc.benjamini_hochberg_alpha
563
+ return 0.0
564
+
565
+
566
+ def _regret_cell(
567
+ model_a: str,
568
+ model_b: str,
569
+ regret_reports: dict[str, RegretReport] | None,
570
+ metric: str,
571
+ ) -> str | None:
572
+ if regret_reports is None:
573
+ return None
574
+ report = regret_reports[metric]
575
+ best = report.apparent_best_model
576
+ if best is None:
577
+ return "n/a"
578
+ if model_a == best:
579
+ alternative = model_b
580
+ elif model_b == best:
581
+ alternative = model_a
582
+ else:
583
+ return "-"
584
+ for regret in report.alternatives:
585
+ if regret.model == alternative:
586
+ return (
587
+ "n/a" if regret.insufficient_data else f"{regret.expected_regret:.2%}"
588
+ )
589
+ return "-"
590
+
591
+
592
+ def _print_fdr_proportion_row(
593
+ model_a: str,
594
+ model_b: str,
595
+ name: str,
596
+ pc: ProportionComparison,
597
+ regret: str | None = None,
598
+ ) -> None:
599
+ regret_suffix = "" if regret is None else f" {regret:>9}"
600
+ if pc.insufficient_data:
601
+ print(
602
+ f"{model_a:<22} {model_b:<22} {name:<14} "
603
+ "insufficient data (n=0 in at least one group)"
604
+ f"{regret_suffix}"
605
+ )
606
+ return
607
+ note = "small n, less reliable" if pc.small_n else ""
608
+ fdr = "yes" if pc.significant_after_benjamini_hochberg else "no"
609
+ bh_alpha = pc.benjamini_hochberg_alpha or 0.0
610
+ print(
611
+ f"{model_a:<22} {model_b:<22} {name:<14} "
612
+ f"{pc.proportion_a:>8.1%} {pc.proportion_b:>8.1%} "
613
+ f"{pc.difference:>+8.1%} {pc.cohens_h:>+7.3f} "
614
+ f"{pc.p_value:>9.4f} {bh_alpha:>9.4f} {fdr:>5}"
615
+ f"{regret_suffix} {pc.n_a:>6} {pc.n_b:>6} {note}"
616
+ )
617
+
618
+
619
+ def _print_compare_all(
620
+ comparison: MultiModelComparison,
621
+ regret_reports: dict[str, RegretReport] | None = None,
622
+ ) -> None:
623
+ print()
624
+ print(f"compare-all: {', '.join(comparison.models)}")
625
+ regret_header = "" if regret_reports is None else f" {'regret':>9}"
626
+ header = (
627
+ f"{'model_a':<22} {'model_b':<22} {'metric':<14} "
628
+ f"{'prop_a':>8} {'prop_b':>8} {'diff':>8} {'h':>7} "
629
+ f"{'p_value':>9} {'bh_alpha':>9} {'fdr':>5}"
630
+ f"{regret_header} {'n_a':>6} {'n_b':>6}"
631
+ )
632
+ print(header)
633
+ for pair in comparison.comparisons:
634
+ _print_fdr_proportion_row(
635
+ pair.model_a,
636
+ pair.model_b,
637
+ "ci_pass_rate",
638
+ pair.ci_pass_rate,
639
+ _regret_cell(pair.model_a, pair.model_b, regret_reports, "ci_pass_rate"),
640
+ )
641
+ _print_fdr_proportion_row(
642
+ pair.model_a,
643
+ pair.model_b,
644
+ "attribution_rate",
645
+ pair.attribution_rate,
646
+ _regret_cell(
647
+ pair.model_a, pair.model_b, regret_reports, "attribution_rate"
648
+ ),
649
+ )
650
+ print(
651
+ f"multiple comparisons: raw alpha={comparison.alpha:.2f}; "
652
+ "Benjamini-Hochberg FDR threshold="
653
+ f"{_bh_threshold_for_display(comparison):.4f} "
654
+ f"over {comparison.tested_family_size} tested p-values "
655
+ f"({comparison.comparison_family_size} total pair-metric slots). "
656
+ "fdr=yes is the BH-controlled rejection set."
657
+ )
658
+ if regret_reports is not None:
659
+ bests = ", ".join(
660
+ f"{metric}={report.apparent_best_model or 'n/a'}"
661
+ for metric, report in regret_reports.items()
662
+ )
663
+ print(
664
+ "regret: expected loss from shipping each metric's apparent best "
665
+ f"if the compared alternative is actually better ({bests})."
666
+ )
667
+ print(
668
+ "repeated-testing caveat: run --compare-all once on a pre-committed "
669
+ "sample window; peeking as data accumulates inflates false positives "
670
+ "beyond the nominal alpha."
671
+ )
672
+
673
+
674
+ def _regret_reports_for_rows(
675
+ rows: list[ModelOutcomeReport],
676
+ ) -> dict[str, RegretReport]:
677
+ return {
678
+ "ci_pass_rate": expected_regret(
679
+ {row.model: (row.ci_passed, row.ci_linked) for row in rows},
680
+ "ci_pass_rate",
681
+ ),
682
+ "attribution_rate": expected_regret(
683
+ {
684
+ row.model: (row.attributed_inference_calls, row.completions)
685
+ for row in rows
686
+ },
687
+ "attribution_rate",
688
+ ),
689
+ }
690
+
691
+
692
+ def _target_mde_payload(
693
+ comparison: ModelComparison,
694
+ target_mde: float | None,
695
+ completions_per_day: tuple[float, float] | None,
696
+ ) -> dict[str, object] | None:
697
+ if target_mde is None:
698
+ return None
699
+ payload: dict[str, object] = {
700
+ "target_mde": target_mde,
701
+ "power": _DEFAULT_POWER,
702
+ "alpha": _DEFAULT_ALPHA,
703
+ "required_n_per_arm": {
704
+ "ci_pass_rate": _target_n_per_arm(comparison.ci_pass_rate, target_mde),
705
+ "attribution_rate": _target_n_per_arm(
706
+ comparison.attribution_rate, target_mde
707
+ ),
708
+ },
709
+ }
710
+ if completions_per_day is not None and all(
711
+ rate > 0 for rate in completions_per_day
712
+ ):
713
+ payload["completions_per_day"] = {
714
+ "model_a": completions_per_day[0],
715
+ "model_b": completions_per_day[1],
716
+ }
717
+ payload["required_days"] = {
718
+ "ci_pass_rate": _target_days(
719
+ comparison.ci_pass_rate,
720
+ target_mde,
721
+ completions_per_day,
722
+ ),
723
+ "attribution_rate": _target_days(
724
+ comparison.attribution_rate,
725
+ target_mde,
726
+ completions_per_day,
727
+ ),
728
+ }
729
+ return payload
730
+
731
+
732
+ def _bootstrap_checks_for_reports(
733
+ report_a: ModelOutcomeReport,
734
+ report_b: ModelOutcomeReport,
735
+ comparison: ModelComparison,
736
+ *,
737
+ bootstrap_iterations: int,
738
+ bootstrap_seed: int,
739
+ ) -> dict[str, BootstrapIntervalDiagnostic]:
740
+ return {
741
+ "ci_pass_rate": bootstrap_two_proportion_interval_diagnostic(
742
+ report_a.ci_passed,
743
+ report_a.ci_linked,
744
+ report_b.ci_passed,
745
+ report_b.ci_linked,
746
+ alpha=comparison.alpha,
747
+ comparison_family_size=comparison.comparison_family_size,
748
+ bootstrap_iterations=bootstrap_iterations,
749
+ seed=bootstrap_seed,
750
+ ),
751
+ "attribution_rate": bootstrap_two_proportion_interval_diagnostic(
752
+ report_a.attributed_inference_calls,
753
+ report_a.completions,
754
+ report_b.attributed_inference_calls,
755
+ report_b.completions,
756
+ alpha=comparison.alpha,
757
+ comparison_family_size=comparison.comparison_family_size,
758
+ bootstrap_iterations=bootstrap_iterations,
759
+ seed=bootstrap_seed,
760
+ ),
761
+ }
762
+
763
+
764
+ def _format_ci(low: float, high: float) -> str:
765
+ return f"[{low:+.1%}, {high:+.1%}]"
766
+
767
+
768
+ def _print_bootstrap_row(name: str, check: BootstrapIntervalDiagnostic) -> None:
769
+ if check.analytic.insufficient_data:
770
+ print(f"{name:<14} bootstrap unavailable (n=0 in at least one group)")
771
+ return
772
+ status = "disagree" if check.substantial_disagreement else "agree"
773
+ reasons = ",".join(check.disagreement_reasons) or "-"
774
+ print(
775
+ f"{name:<14} {_format_ci(check.analytic.ci_low, check.analytic.ci_high):>20} "
776
+ f"{_format_ci(check.bootstrap_ci_low, check.bootstrap_ci_high):>20} "
777
+ f"{check.ci_overlap_fraction:>7.2f} "
778
+ f"{str(check.analytic_point_in_bootstrap_ci):>8} "
779
+ f"{status:>9} {reasons}"
780
+ )
781
+
782
+
783
+ def _print_bootstrap_checks(
784
+ checks: dict[str, BootstrapIntervalDiagnostic],
785
+ ) -> None:
786
+ if not checks:
787
+ return
788
+ first = next(iter(checks.values()))
789
+ print()
790
+ print(
791
+ "bootstrap interval diagnostic: "
792
+ f"B={first.bootstrap_iterations}, seed={first.seed}, "
793
+ f"confidence={first.confidence:.0%}"
794
+ )
795
+ print(
796
+ f"{'metric':<14} {'analytic_ci':>20} {'bootstrap_ci':>20} "
797
+ f"{'overlap':>7} {'point_in':>8} {'status':>9} reasons"
798
+ )
799
+ for name, check in checks.items():
800
+ _print_bootstrap_row(name, check)
801
+
802
+
803
+ def _print_comparison(
804
+ comparison: ModelComparison,
805
+ target_mde: float | None = None,
806
+ completions_per_day: tuple[float, float] | None = None,
807
+ bootstrap_checks: dict[str, BootstrapIntervalDiagnostic] | None = None,
808
+ ) -> None:
809
+ print()
810
+ print(f"compare: {comparison.model_a} vs {comparison.model_b}")
811
+ if comparison.information_fraction is None:
812
+ sequential_header = ""
813
+ else:
814
+ sequential_header = f" {'seq_z':>8} {'seq_alpha':>9} {'seq_sig':>9}"
815
+ header = (
816
+ f"{'metric':<14} {'prop_a':>8} {'prop_b':>8} {'diff':>8} {'h':>7} "
817
+ f"{'ci_low':>8} {'ci_high':>8} {'z':>7} {'p_value':>9} "
818
+ f"{'sig':>10}{sequential_header} {'n_a':>6} {'n_b':>6}"
819
+ )
820
+ print(header)
821
+ show_sequential = comparison.information_fraction is not None
822
+ _print_proportion_row("ci_pass_rate", comparison.ci_pass_rate, show_sequential)
823
+ _print_proportion_row(
824
+ "attribution_rate", comparison.attribution_rate, show_sequential
825
+ )
826
+ print(
827
+ f"multiple comparisons: raw alpha={comparison.alpha:.2f}; "
828
+ "Bonferroni-adjusted threshold="
829
+ f"{comparison.bonferroni_alpha:.4f} "
830
+ f"for {comparison.comparison_family_size} tests. "
831
+ "sig=corrected clears both thresholds; sig=raw_only clears only raw alpha."
832
+ )
833
+ if comparison.information_fraction is not None:
834
+ print(
835
+ "sequential correction: O'Brien-Fleming "
836
+ f"information_fraction={comparison.information_fraction:.4f}; "
837
+ "seq_sig=yes crosses the peeking-corrected boundary, "
838
+ "seq_sig=not_yet does not."
839
+ )
840
+ print(
841
+ "repeated-testing caveat: run --compare once on a pre-committed "
842
+ "sample window; peeking as data accumulates inflates false positives "
843
+ "beyond the nominal alpha."
844
+ )
845
+ print()
846
+ print(f"power analysis: alpha={_DEFAULT_ALPHA:.2f}, power={_DEFAULT_POWER:.0%}")
847
+ _print_power_row(
848
+ "ci_pass_rate",
849
+ comparison.ci_pass_rate,
850
+ target_mde,
851
+ completions_per_day,
852
+ )
853
+ _print_power_row(
854
+ "attribution_rate",
855
+ comparison.attribution_rate,
856
+ target_mde,
857
+ completions_per_day,
858
+ )
859
+ _print_bayesian_comparison(comparison)
860
+ if bootstrap_checks is not None:
861
+ _print_bootstrap_checks(bootstrap_checks)
862
+
863
+
864
+ def _attribution_outcomes_by_model(
865
+ completions: list[InferenceCall],
866
+ attributed_completions: list[AttributedCompletion],
867
+ since_days: int | None,
868
+ now: datetime,
869
+ *,
870
+ repository_context: RepositoryContext | None = None,
871
+ ci_population=None,
872
+ ) -> dict[str, list[bool]]:
873
+ windowed_calls, windowed_rows = model_report_inputs(
874
+ completions,
875
+ attributed_completions,
876
+ since_days,
877
+ now,
878
+ repository_context=repository_context,
879
+ ci_population=ci_population,
880
+ )
881
+ surviving_ids = {
882
+ row.inference_call_id for row in windowed_rows if row.abandonment is None
883
+ }
884
+ outcomes: dict[str, list[bool]] = {}
885
+ for completion in sorted(
886
+ windowed_calls,
887
+ key=lambda completion: (
888
+ inference_observed_at(completion),
889
+ inference_fact_id(completion),
890
+ ),
891
+ ):
892
+ model = inference_model(completion)
893
+ if model is not None:
894
+ outcomes.setdefault(model, []).append(
895
+ inference_fact_id(completion) in surviving_ids
896
+ )
897
+ return outcomes
898
+
899
+
900
+ def _ci_outcomes_by_model(
901
+ attributed_completions: list[AttributedCompletion],
902
+ completions: list[InferenceCall],
903
+ since_days: int | None,
904
+ now: datetime,
905
+ grain: CIGrain,
906
+ *,
907
+ repository_context: RepositoryContext | None = None,
908
+ ci_population=None,
909
+ ) -> dict[str, list[bool]]:
910
+ ci_population = prepare_report_ci(
911
+ ci_population, attributed_completions, repository_context, now
912
+ )
913
+ calls, source_rows = model_report_inputs(
914
+ completions,
915
+ attributed_completions,
916
+ since_days,
917
+ now,
918
+ repository_context=repository_context,
919
+ ci_population=ci_population,
920
+ )
921
+ call_by_id = {inference_fact_id(call): call for call in calls}
922
+ groups: dict[str, list[AttributedCompletion]] = {}
923
+ for row in source_rows:
924
+ model = inference_model(call_by_id[row.inference_call_id])
925
+ if model is not None:
926
+ groups.setdefault(model, []).append(row)
927
+ outcomes = {
928
+ model: [
929
+ trial.resolution.verdict == CIResult.PASSED
930
+ for trial in model_ci_trials(
931
+ members,
932
+ grain,
933
+ repository_context=repository_context,
934
+ ci_population=ci_population,
935
+ )
936
+ ]
937
+ for model, members in sorted(groups.items())
938
+ }
939
+ return {model: values for model, values in outcomes.items() if values}
940
+
941
+
942
+ def _effect_decay_reports(
943
+ completions: list[InferenceCall],
944
+ attributed_completions: list[AttributedCompletion],
945
+ model_a: str,
946
+ model_b: str,
947
+ since_days: int | None,
948
+ now: datetime,
949
+ grain: CIGrain,
950
+ *,
951
+ repository_context: RepositoryContext | None = None,
952
+ ci_population=None,
953
+ ) -> dict[str, EffectDecayReport]:
954
+ attribution = _attribution_outcomes_by_model(
955
+ completions,
956
+ attributed_completions,
957
+ since_days,
958
+ now,
959
+ repository_context=repository_context,
960
+ ci_population=ci_population,
961
+ )
962
+ ci = _ci_outcomes_by_model(
963
+ attributed_completions,
964
+ completions,
965
+ since_days,
966
+ now,
967
+ grain,
968
+ repository_context=repository_context,
969
+ ci_population=ci_population,
970
+ )
971
+ return {
972
+ "ci_pass_rate": effect_decay_check(
973
+ ci.get(model_a, []),
974
+ ci.get(model_b, []),
975
+ ),
976
+ "attribution_rate": effect_decay_check(
977
+ attribution.get(model_a, []),
978
+ attribution.get(model_b, []),
979
+ ),
980
+ }
981
+
982
+
983
+ def _fmt_rate(numerator: int, denominator: int, rate: float) -> str:
984
+ return f"{rate:.1%} ({numerator}/{denominator})"
985
+
986
+
987
+ def _fmt_raw_to_shrunk(
988
+ numerator: int, denominator: int, raw_rate: float, shrunk_rate: float
989
+ ) -> str:
990
+ return f"{raw_rate:.1%} -> {shrunk_rate:.1%} ({numerator}/{denominator})"
991
+
992
+
993
+ def _fmt_delta(delta: float) -> str:
994
+ return f"{delta:+.1%}"
995
+
996
+
997
+ def _print_adjusted_comparisons(check: StratificationCheck) -> None:
998
+ if not check.adjusted_comparisons:
999
+ return
1000
+ print(" Mantel-Haenszel adjusted risk difference (CI/p-value not computed):")
1001
+ for comparison in check.adjusted_comparisons:
1002
+ left = comparison.aggregate_left
1003
+ right = comparison.aggregate_right
1004
+ print(
1005
+ f" - {comparison.left_model} vs {comparison.right_model}: "
1006
+ f"naive {_fmt_delta(comparison.naive_delta)} "
1007
+ f"[{_fmt_rate(left.numerator, left.denominator, left.rate)} vs "
1008
+ f"{_fmt_rate(right.numerator, right.denominator, right.rate)}], "
1009
+ f"adjusted {_fmt_delta(comparison.adjusted_delta)} "
1010
+ f"({comparison.compared_repos} repo strata, "
1011
+ f"{comparison.skipped_repos} skipped)"
1012
+ )
1013
+
1014
+
1015
+ def _print_shrunk_rates(check: StratificationCheck) -> None:
1016
+ if not check.shrunk_rates:
1017
+ return
1018
+ print(" shrunk repo rates:")
1019
+ for entry in check.shrunk_rates:
1020
+ label = format_repository_label(entry.repo, entry.repository_identity)
1021
+ cells = ", ".join(
1022
+ f"{model}: {_fmt_raw_to_shrunk(rate.numerator, rate.denominator, rate.raw_rate, rate.shrunk_rate)}"
1023
+ for model, rate in sorted(entry.model_rates.items())
1024
+ )
1025
+ print(f" {label}: {cells}")
1026
+
1027
+
1028
+ def _print_stratification(checks: list[StratificationCheck]) -> None:
1029
+ print()
1030
+ print("stratification")
1031
+ for check in checks:
1032
+ if check.status == StratificationStatus.REVERSAL_DETECTED:
1033
+ print(f"{check.metric}: reversal detected")
1034
+ for comparison in check.comparisons:
1035
+ if not comparison.reversals:
1036
+ continue
1037
+ left = comparison.aggregate_left
1038
+ right = comparison.aggregate_right
1039
+ print(
1040
+ f" {comparison.left_model} vs {comparison.right_model}: "
1041
+ f"aggregate delta {_fmt_delta(comparison.aggregate_delta)} "
1042
+ f"[{_fmt_rate(left.numerator, left.denominator, left.rate)} vs "
1043
+ f"{_fmt_rate(right.numerator, right.denominator, right.rate)}]"
1044
+ )
1045
+ for reversal in comparison.reversals:
1046
+ repo_left = reversal.left
1047
+ repo_right = reversal.right
1048
+ print(
1049
+ f" - {reversal.repo}: repo delta "
1050
+ f"{_fmt_delta(reversal.repo_delta)} "
1051
+ f"[{_fmt_rate(repo_left.numerator, repo_left.denominator, repo_left.rate)} "
1052
+ f"vs "
1053
+ f"{_fmt_rate(repo_right.numerator, repo_right.denominator, repo_right.rate)}]"
1054
+ )
1055
+ _print_shrunk_rates(check)
1056
+ elif check.status == StratificationStatus.NO_REVERSAL_DETECTED:
1057
+ print(
1058
+ f"{check.metric}: no reversal detected "
1059
+ f"({check.checked_repos} repos checked)"
1060
+ )
1061
+ _print_shrunk_rates(check)
1062
+ else:
1063
+ print(f"{check.metric}: {check.status} ({check.reason})")
1064
+ _print_shrunk_rates(check)
1065
+ _print_adjusted_comparisons(check)
1066
+
1067
+
1068
+ def _trend_payload(trend: ModelTemporalTrend) -> dict[str, object]:
1069
+ payload = asdict(trend)
1070
+ for window in payload["windows"]:
1071
+ window["window_start"] = window["window_start"].isoformat()
1072
+ window["window_end"] = window["window_end"].isoformat()
1073
+ return payload
1074
+
1075
+
1076
+ def _fmt_trend_p_value(p_value: float | None) -> str:
1077
+ return "-" if p_value is None else f"{p_value:.4f}"
1078
+
1079
+
1080
+ def _print_trends(trends: list[ModelTemporalTrend]) -> None:
1081
+ print()
1082
+ print("trend")
1083
+ if not trends:
1084
+ print("no data")
1085
+ return
1086
+ header = (
1087
+ f"{'model':<28} {'metric':<14} {'windows':>7} {'status':<24} "
1088
+ f"{'S':>5} {'z':>7} {'p_value':>9} {'latest':>17}"
1089
+ )
1090
+ print(header)
1091
+ for trend in trends:
1092
+ latest = trend.windows[-1] if trend.windows else None
1093
+ latest_text = (
1094
+ _fmt_rate(latest.numerator, latest.denominator, latest.rate)
1095
+ if latest is not None
1096
+ else "-"
1097
+ )
1098
+ print(
1099
+ f"{trend.model:<28} {trend.metric:<14} "
1100
+ f"{trend.test.sample_size:>7} {trend.test.status:<24} "
1101
+ f"{trend.test.s_statistic:>5} {trend.test.z_statistic:>7.2f} "
1102
+ f"{_fmt_trend_p_value(trend.test.p_value):>9} {latest_text:>17}"
1103
+ )
1104
+ if trend.test.status == TrendStatus.INSUFFICIENT_DATA:
1105
+ print(f"{'':<28} {'':<14} {trend.test.reason}")
1106
+
1107
+
1108
+ def build_parser() -> argparse.ArgumentParser:
1109
+ """The argv contract for this report, extracted so the generated
1110
+ CLI reference can walk it."""
1111
+ parser = argparse.ArgumentParser(
1112
+ prog="model_report", description=__doc__.splitlines()[0]
1113
+ )
1114
+ parser.add_argument(
1115
+ "--org",
1116
+ default=os.environ.get("SEDIMENT_ORG_ID"),
1117
+ required=not os.environ.get("SEDIMENT_ORG_ID"),
1118
+ help="org id (default: $SEDIMENT_ORG_ID)",
1119
+ )
1120
+ parser.add_argument(
1121
+ "--since-days",
1122
+ type=int,
1123
+ default=None,
1124
+ help="window inference calls (and their attributed completions) to the last N days by "
1125
+ "capture time; default: all history",
1126
+ )
1127
+ parser.add_argument(
1128
+ "--json", action="store_true", help="emit JSON rows instead of a table"
1129
+ )
1130
+ parser.add_argument(
1131
+ "--trend",
1132
+ action="store_true",
1133
+ help="also bucket attribution_rate and ci_pass_rate into 7-day "
1134
+ "captured-at windows and run a Mann-Kendall monotonic trend test",
1135
+ )
1136
+ parser.add_argument(
1137
+ "--target-mde",
1138
+ type=float,
1139
+ default=None,
1140
+ help="with --compare, also report the equal-allocation n per arm "
1141
+ "needed to detect this target effect, expressed as raw proportion "
1142
+ "points (0.05 means five percentage points)",
1143
+ )
1144
+ parser.add_argument(
1145
+ "--completions-per-day",
1146
+ type=float,
1147
+ nargs=2,
1148
+ metavar=("MODEL_A_PER_DAY", "MODEL_B_PER_DAY"),
1149
+ default=None,
1150
+ help="with --target-mde, also estimate calendar days using per-arm "
1151
+ "completions/day in the same order as --compare; zero or negative "
1152
+ "rates omit the duration line",
1153
+ )
1154
+ parser.add_argument(
1155
+ "--bootstrap-check",
1156
+ action="store_true",
1157
+ help="with --compare, also run a fixed-seed bootstrap percentile CI "
1158
+ "diagnostic against the analytic two-proportion CI",
1159
+ )
1160
+ parser.add_argument(
1161
+ "--bootstrap-iterations",
1162
+ type=int,
1163
+ default=None,
1164
+ help="resamples for --bootstrap-check "
1165
+ f"(default: {DEFAULT_BOOTSTRAP_ITERATIONS})",
1166
+ )
1167
+ parser.add_argument(
1168
+ "--bootstrap-seed",
1169
+ type=int,
1170
+ default=None,
1171
+ help="deterministic RNG seed for --bootstrap-check "
1172
+ f"(default: {DEFAULT_BOOTSTRAP_SEED})",
1173
+ )
1174
+ parser.add_argument(
1175
+ "--non-inferiority-margin",
1176
+ type=float,
1177
+ default=None,
1178
+ help="with --compare, test whether MODEL_B is not worse than MODEL_A "
1179
+ "by more than this raw proportion-point margin (0.05 means five "
1180
+ "percentage points)",
1181
+ )
1182
+ parser.add_argument(
1183
+ "--peek-fraction",
1184
+ type=float,
1185
+ default=None,
1186
+ help="with --compare, also report an O'Brien-Fleming sequential "
1187
+ "boundary read for this information fraction (0 < t <= 1)",
1188
+ )
1189
+ parser.add_argument(
1190
+ "--ci-grain",
1191
+ choices=[g.value for g in CIGrain],
1192
+ default=CIGrain.COMMIT.value,
1193
+ help="unit of analysis for ci_linked/ci_passed: 'commit' "
1194
+ "(default, correct) dedups a commit's file-grained attributed completions to one trial "
1195
+ "before counting; 'attributed_completion' is the legacy per-attributed-completion count, "
1196
+ "kept only as an escape hatch for explicit comparison — it "
1197
+ "inflates sample size and is not recommended for inference",
1198
+ )
1199
+ parser.add_argument(
1200
+ "--compare",
1201
+ nargs=2,
1202
+ metavar=("MODEL_A", "MODEL_B"),
1203
+ help="two-proportion z-test of MODEL_A vs MODEL_B on CI pass rate "
1204
+ "and attribution rate, over the same --org/--since-days window; "
1205
+ "a model with no data in this window compares as insufficient data, "
1206
+ "not an error",
1207
+ )
1208
+ parser.add_argument(
1209
+ "--compare-all",
1210
+ nargs="+",
1211
+ metavar="MODEL",
1212
+ help="pairwise two-proportion z-tests for 2+ models with "
1213
+ "Benjamini-Hochberg FDR control across both metrics and all pairs; "
1214
+ "a model with no data in this window compares as "
1215
+ "insufficient data, not an error",
1216
+ )
1217
+ parser.add_argument(
1218
+ "--effect-decay",
1219
+ action="store_true",
1220
+ help="with --compare, print a regression-to-the-mean diagnostic: "
1221
+ "Cohen's h at 25/50/75/100%% prefixes for CI pass rate and attribution "
1222
+ "rate, using natural arrival order",
1223
+ )
1224
+ parser.add_argument(
1225
+ "--regret",
1226
+ action="store_true",
1227
+ help="with --compare-all, add expected regret for the apparent-best "
1228
+ "model on each metric",
1229
+ )
1230
+ add_database_url_argument(parser)
1231
+ parser.add_argument(
1232
+ "--mirror-path",
1233
+ default=os.environ.get("SEDIMENT_MIRROR_PATH", _DEFAULT_MIRROR_PATH),
1234
+ help="base dir for git mirrors (default: $SEDIMENT_MIRROR_PATH or "
1235
+ f"{_DEFAULT_MIRROR_PATH!r})",
1236
+ )
1237
+ return parser
1238
+
1239
+
1240
+ def main(argv: list[str] | None = None) -> int:
1241
+ args = build_parser().parse_args(argv)
1242
+
1243
+ if args.since_days is not None and args.since_days <= 0:
1244
+ print("error: --since-days must be a positive integer", file=sys.stderr)
1245
+ return 2
1246
+ if args.target_mde is not None and not 0 < args.target_mde <= 1:
1247
+ print("error: --target-mde must be > 0 and <= 1", file=sys.stderr)
1248
+ return 2
1249
+ if args.compare and args.compare_all:
1250
+ print("error: use --compare or --compare-all, not both", file=sys.stderr)
1251
+ return 2
1252
+ if args.compare and args.compare[0] == args.compare[1]:
1253
+ print(
1254
+ "error: --compare requires two different model names",
1255
+ file=sys.stderr,
1256
+ )
1257
+ return 2
1258
+ if args.compare_all is not None and len(args.compare_all) < 2:
1259
+ print("error: --compare-all requires at least two models", file=sys.stderr)
1260
+ return 2
1261
+ if args.compare_all is not None and len(set(args.compare_all)) != len(
1262
+ args.compare_all
1263
+ ):
1264
+ print("error: --compare-all requires unique model names", file=sys.stderr)
1265
+ return 2
1266
+ if (
1267
+ args.bootstrap_check
1268
+ and args.bootstrap_iterations is not None
1269
+ and args.bootstrap_iterations < 2
1270
+ ):
1271
+ print("error: --bootstrap-iterations must be at least 2", file=sys.stderr)
1272
+ return 2
1273
+ if args.non_inferiority_margin is not None and not (
1274
+ 0 < args.non_inferiority_margin <= 1
1275
+ ):
1276
+ print("error: --non-inferiority-margin must be > 0 and <= 1", file=sys.stderr)
1277
+ return 2
1278
+ if args.peek_fraction is not None and not 0 < args.peek_fraction <= 1:
1279
+ print("error: --peek-fraction must be > 0 and <= 1", file=sys.stderr)
1280
+ return 2
1281
+ for flag, given, requires, present in (
1282
+ ("--target-mde", args.target_mde is not None, "--compare", args.compare),
1283
+ (
1284
+ "--completions-per-day",
1285
+ args.completions_per_day is not None,
1286
+ "--target-mde",
1287
+ args.target_mde is not None,
1288
+ ),
1289
+ ("--bootstrap-check", args.bootstrap_check, "--compare", args.compare),
1290
+ (
1291
+ "--bootstrap-iterations",
1292
+ args.bootstrap_iterations is not None,
1293
+ "--bootstrap-check",
1294
+ args.bootstrap_check,
1295
+ ),
1296
+ (
1297
+ "--bootstrap-seed",
1298
+ args.bootstrap_seed is not None,
1299
+ "--bootstrap-check",
1300
+ args.bootstrap_check,
1301
+ ),
1302
+ (
1303
+ "--non-inferiority-margin",
1304
+ args.non_inferiority_margin is not None,
1305
+ "--compare",
1306
+ args.compare,
1307
+ ),
1308
+ ("--peek-fraction", args.peek_fraction is not None, "--compare", args.compare),
1309
+ ("--effect-decay", args.effect_decay, "--compare", args.compare),
1310
+ ("--regret", args.regret, "--compare-all", args.compare_all),
1311
+ ):
1312
+ if given and not present:
1313
+ print(f"error: {flag} requires {requires}", file=sys.stderr)
1314
+ return 2
1315
+ completions_per_day = (
1316
+ tuple(args.completions_per_day)
1317
+ if args.completions_per_day is not None
1318
+ else None
1319
+ )
1320
+ bootstrap_iterations = (
1321
+ args.bootstrap_iterations
1322
+ if args.bootstrap_iterations is not None
1323
+ else DEFAULT_BOOTSTRAP_ITERATIONS
1324
+ )
1325
+ bootstrap_seed = (
1326
+ args.bootstrap_seed
1327
+ if args.bootstrap_seed is not None
1328
+ else DEFAULT_BOOTSTRAP_SEED
1329
+ )
1330
+
1331
+ try:
1332
+ org_id = normalize_org_id(args.org)
1333
+ except ValueError as exc:
1334
+ print(f"error: {exc}", file=sys.stderr)
1335
+ return 2
1336
+
1337
+ report_policy = OutcomeReportPolicy(ci_grain=CIGrain(args.ci_grain))
1338
+ now = datetime.now(UTC)
1339
+ try:
1340
+ with one_shot_fact_store(
1341
+ args.database_url, operation="generate model report"
1342
+ ) as store:
1343
+ mirrors = MirrorManager(args.mirror_path)
1344
+ if args.since_days is not None:
1345
+ operational = generate_operational_model_report(
1346
+ store,
1347
+ mirrors,
1348
+ org_id,
1349
+ OperationalReportScope.trailing_days(args.since_days, as_of=now),
1350
+ report_policy=report_policy,
1351
+ include_trends=args.trend,
1352
+ )
1353
+ ci_population = operational.ci_population
1354
+ repository_context = operational.repository_context
1355
+ result = operational.result
1356
+ completions = operational.completions
1357
+ attributed_completions = operational.attributed_completions
1358
+ else:
1359
+ with store.read_snapshot() as snapshot:
1360
+ repository_context = read_repository_context(
1361
+ snapshot, org_id, as_of=now
1362
+ )
1363
+ ci_population = snapshot.read_ci_outcome_projections(
1364
+ org_id, captured_through=now, limit=50_000
1365
+ )
1366
+ completions = snapshot.read_rollout_inference_calls(org_id)
1367
+ assembly = assemble_attributed_completions_result(
1368
+ snapshot,
1369
+ mirrors,
1370
+ org_id,
1371
+ ci_population=ci_population,
1372
+ repository_context=repository_context,
1373
+ as_of=now,
1374
+ )
1375
+ attributed_completions = assembly.rows
1376
+ attribution_share, attribution_alerts = (
1377
+ derive_model_report_attribution_share(
1378
+ snapshot,
1379
+ mirrors,
1380
+ org_id,
1381
+ None,
1382
+ now=now,
1383
+ repository_context=repository_context,
1384
+ )
1385
+ )
1386
+ quarantine_revision = snapshot.quarantine_revision(org_id)
1387
+ fate_result = derive_fate_result(
1388
+ [
1389
+ observation
1390
+ for observation in snapshot.read_edit_observation_projections(
1391
+ org_id
1392
+ )
1393
+ if observation.captured_at <= now
1394
+ ],
1395
+ four_gram_containment,
1396
+ quarantine_revision=quarantine_revision,
1397
+ )
1398
+ result = build_model_report_result(
1399
+ completions,
1400
+ attributed_completions,
1401
+ ci_population=ci_population,
1402
+ since_days=None,
1403
+ now=now,
1404
+ decisions=snapshot.read_decision_projections(org_id),
1405
+ policy=report_policy,
1406
+ include_trends=args.trend,
1407
+ attribution_share=attribution_share,
1408
+ attribution_alerts=attribution_alerts,
1409
+ abandonment=build_abandonment_summary(assembly),
1410
+ quarantine_revision=quarantine_revision,
1411
+ fate_result=fate_result,
1412
+ repository_context=repository_context,
1413
+ )
1414
+ result = with_model_assembly_diagnostics(result, assembly)
1415
+ except Exception as exc:
1416
+ print(f"error: couldn't generate model report: {exc}", file=sys.stderr)
1417
+ return 1
1418
+ rows = result.rows
1419
+
1420
+ comparison = None
1421
+ bootstrap_checks = None
1422
+ comparison_all = None
1423
+ non_inferiority = None
1424
+ effect_decay = None
1425
+ regret_reports = None
1426
+ if args.compare:
1427
+ model_a, model_b = args.compare
1428
+ report_a = _row_for_model(rows, model_a, args.since_days)
1429
+ report_b = _row_for_model(rows, model_b, args.since_days)
1430
+ comparison = compare_models(
1431
+ report_a, report_b, information_fraction=args.peek_fraction
1432
+ )
1433
+ if args.bootstrap_check:
1434
+ bootstrap_checks = _bootstrap_checks_for_reports(
1435
+ report_a,
1436
+ report_b,
1437
+ comparison,
1438
+ bootstrap_iterations=bootstrap_iterations,
1439
+ bootstrap_seed=bootstrap_seed,
1440
+ )
1441
+ if args.non_inferiority_margin is not None:
1442
+ non_inferiority = _non_inferiority_results(
1443
+ report_a,
1444
+ report_b,
1445
+ args.non_inferiority_margin,
1446
+ )
1447
+ if args.effect_decay:
1448
+ effect_decay = _effect_decay_reports(
1449
+ completions,
1450
+ attributed_completions,
1451
+ model_a,
1452
+ model_b,
1453
+ args.since_days,
1454
+ now,
1455
+ report_policy.ci_grain,
1456
+ ci_population=ci_population,
1457
+ repository_context=repository_context,
1458
+ )
1459
+ elif args.compare_all:
1460
+ compare_all_rows = [
1461
+ _row_for_model(rows, model, args.since_days) for model in args.compare_all
1462
+ ]
1463
+ comparison_all = compare_all_models(compare_all_rows)
1464
+ if args.regret:
1465
+ regret_reports = _regret_reports_for_rows(compare_all_rows)
1466
+
1467
+ if args.json:
1468
+ payload = base_model_report_payload(result)
1469
+ if args.trend:
1470
+ payload["trends"] = [_trend_payload(t) for t in result.trends]
1471
+ if comparison is not None:
1472
+ payload["comparison"] = asdict(comparison)
1473
+ target_payload = _target_mde_payload(
1474
+ comparison,
1475
+ args.target_mde,
1476
+ completions_per_day,
1477
+ )
1478
+ if target_payload is not None:
1479
+ payload["target_mde"] = target_payload
1480
+ if bootstrap_checks is not None:
1481
+ payload["bootstrap_interval_diagnostic"] = {
1482
+ name: asdict(check) for name, check in bootstrap_checks.items()
1483
+ }
1484
+ if non_inferiority is not None:
1485
+ payload["non_inferiority"] = {
1486
+ metric: asdict(result) for metric, result in non_inferiority.items()
1487
+ }
1488
+ if effect_decay is not None:
1489
+ payload["effect_decay"] = {
1490
+ metric: asdict(report) for metric, report in effect_decay.items()
1491
+ }
1492
+ if comparison_all is not None:
1493
+ payload["comparison_all"] = asdict(comparison_all)
1494
+ if regret_reports is not None:
1495
+ payload["regret"] = {
1496
+ metric: asdict(report) for metric, report in regret_reports.items()
1497
+ }
1498
+ print(json.dumps(payload, indent=2))
1499
+ else:
1500
+ window = f"last {args.since_days} day(s)" if args.since_days else "all history"
1501
+ print(f"org: {org_id} window: {window}")
1502
+ _print_attribution_share(result.attribution_share, result.attribution_alerts)
1503
+ _print_table(rows)
1504
+ for name, populations in (
1505
+ ("repository", result.repository_skipped),
1506
+ ("CI", result.ci_skipped),
1507
+ ):
1508
+ for unit, reasons in sorted(populations.items()):
1509
+ print(f"{name} skips ({unit}): {_format_breakdown(reasons)}")
1510
+ _print_fate_diagnostics(result)
1511
+ _print_signal_funnel(result.signal_funnel)
1512
+ _print_abandonment(result.abandonment)
1513
+ if comparison is not None:
1514
+ _print_comparison(
1515
+ comparison,
1516
+ args.target_mde,
1517
+ completions_per_day,
1518
+ bootstrap_checks,
1519
+ )
1520
+ if non_inferiority is not None:
1521
+ _print_non_inferiority(comparison, non_inferiority)
1522
+ if effect_decay is not None:
1523
+ _print_effect_decay(effect_decay)
1524
+ if comparison_all is not None:
1525
+ _print_compare_all(comparison_all, regret_reports)
1526
+ if rows:
1527
+ _print_stratification(result.stratification)
1528
+ if args.trend:
1529
+ _print_trends(result.trends)
1530
+ return 0
1531
+
1532
+
1533
+ if __name__ == "__main__":
1534
+ raise SystemExit(main())