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.
- sediment_api/__init__.py +6 -0
- sediment_api/config.py +230 -0
- sediment_api/database.py +43 -0
- sediment_api/deps.py +191 -0
- sediment_api/main.py +212 -0
- sediment_api/mirror_gc.py +151 -0
- sediment_api/reports/__init__.py +9 -0
- sediment_api/reports/abandonment_report.py +123 -0
- sediment_api/reports/attribution_share_report.py +298 -0
- sediment_api/reports/dataset_diagnostics.py +311 -0
- sediment_api/reports/label_confidence_inspection.py +438 -0
- sediment_api/reports/lifecycle_report.py +73 -0
- sediment_api/reports/merge_retention_report.py +165 -0
- sediment_api/reports/model_report.py +1534 -0
- sediment_api/reports/precision_report.py +182 -0
- sediment_api/reports/recovery_yield_report.py +132 -0
- sediment_api/routers/__init__.py +3 -0
- sediment_api/routers/ci_vendor.py +149 -0
- sediment_api/routers/forge.py +452 -0
- sediment_api/routers/gateway.py +137 -0
- sediment_api/routers/otlp.py +120 -0
- sediment_api/routers/query.py +1010 -0
- sediment_api/routers/reports.py +116 -0
- sediment_api/routers/v1.py +170 -0
- sediment_api/services/__init__.py +2 -0
- sediment_api/services/operational_reports.py +391 -0
- sediment_api/worker.py +242 -0
- sediment_api/workers.py +382 -0
- sediment_api-0.1.0.dist-info/METADATA +20 -0
- sediment_api-0.1.0.dist-info/RECORD +32 -0
- sediment_api-0.1.0.dist-info/WHEEL +4 -0
- sediment_api-0.1.0.dist-info/licenses/LICENSE +661 -0
|
@@ -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())
|