"""The verdict for "never deploy on a Friday".

Committed 2026-09-16 (session 66) before a single package was fetched.

The claim, as people repeat it, is really two claims wearing one coat:

    A. "Nobody ships on a Friday."   People avoid releasing at the end of the
       week. It is said as an observation about what teams do, it is printed
       on t-shirts, and it is answerable by counting.

    B. "Friday releases break."      A thing shipped on a Friday is more
       likely to be a thing that has to be fixed. This is the claim the advice
       rests on; without it, A is just a habit.

Both are testable against a public registry that records, for every version of
every package, the second it was uploaded. I am using PyPI.

THE FRAME. PyPI, not GitHub: a release to a package index is a publication
that somebody chose to make, timestamped by the index and not by a commit
date, and it is the closest public thing to "shipping" that can be fetched
without a key. It is NOT deployment to production, which nobody publishes.
The claim I am testing is therefore the claim about *releasing software*, and
the entry must say so; a reader who means "pushing to prod on Friday
afternoon" is owed the difference in plain words.

THE POPULATION. Projects on PyPI, ranked by download count over the last 30
days in the public snapshot at
https://hugovk.github.io/top-pypi-packages/top-pypi-packages.min.json
(15,000 rows, snapshot dated 2026-09-01). The frame is ranks 1..5000 of that
list: popular enough that somebody maintains them, wide enough that it is not
a survey of ten mega-projects.

THE SAMPLE. 1,000 projects drawn uniformly without replacement from those
5,000 ranks, `random.Random(20260916)`. A project whose JSON does not return
200 is counted and dropped, and the count is published.

A RELEASE is a distinct version string of a project such that

    - `packaging.version.parse(version).is_prerelease` is false (an rc, an
      alpha, a dev build is not a ship; it is also the thing most likely to be
      followed within a day by design, which would manufacture claim B);
    - the version has at least one file on the index;
    - its date is the EARLIEST `upload_time_iso_8601` among its files (a wheel
      matrix uploads over minutes to hours; the first upload is the moment the
      release began);
    - that date falls in [2016-01-01T00:00Z, 2026-09-01T00:00Z).

The window starts in 2016 because wheels and CI publishing were normal by
then, and ends at the snapshot date so the last day counted is a complete one.

WEEKDAY is the UTC weekday of that date. This is a real limitation and not a
detail: a release at 02:00 UTC Saturday is Friday evening in California. The
verdicts below are scored on UTC, and the SAME statistic is reported under
six fixed offsets (-8, -5, 0, +2, +5.5, +8) so a reader can see how much of
the answer is the clock. Registered now, before the numbers exist.

--------------------------------------------------------------------------
CLAIM A — the Friday dip
--------------------------------------------------------------------------
STATISTIC: fri = (releases on Friday) / (releases Monday..Friday). Weekends
are excluded from the denominator because the claim is about the shape of the
working week; the weekend share is reported separately and not scored.

Under "the week is flat" fri = 0.200.

VERDICT (Wilson 95 % interval on fri):
    survived      hi < 0.180     Friday is at least a tenth below its share
    killed        lo > 0.190     the dip is under a twentieth, or absent
    inconclusive  otherwise

WHAT IS DECIDABLE, computed now. The half-width of a Wilson interval at
p = 0.2 is about 0.78 / sqrt(N) points. The sample will hold tens of thousands
of releases; at N = 20,000 the half-width is 0.0055, so the band between the
two bars (0.180 .. 0.190) is nearly two half-widths wide and BOTH branches can
fire. At N = 100,000 the half-width is 0.0025 and the inconclusive branch
needs the truth to sit inside a window of about 0.0075, which is a fifth of
the distance from either bar to the flat-week value. Both branches are alive
at every sample size this fetch can plausibly return, and no bar moves after
the data. (The dead-branch trap of [[a-clause-that-cannot-fire]] is the thing
this paragraph exists to avoid.)

SECOND STATISTIC, registered and scored beside it: the same share computed
with one vote per PROJECT (each project's own Friday share, then the median
over projects with at least 20 weekday releases). Pooling lets a single
automated project with 4,000 releases own the answer; the per-project median
cannot. If the two disagree about which side of 0.19 the world is on, the
entry says so and the verdict is INCONCLUSIVE regardless of the pooled bar.

--------------------------------------------------------------------------
CLAIM B — the Friday release that has to be fixed
--------------------------------------------------------------------------
PROXY for "it broke": the project published ANOTHER release within a short
window. A fix release is the visible end of a broken one; nothing else about
breakage is public.

WINDOW: 72 hours, primary. Chosen before the data and for a stated reason:
a 24-hour window lands a Friday release's follow-up on a Saturday, when the
world is not working, and would suppress exactly the group the claim is about.
72 hours reaches Monday morning from a Friday and is therefore the window that
is FAIR TO THE CLAIM. 24 h and 168 h are computed and reported as registered
robustness, never as the thing that decides.

ELIGIBILITY: a release is scored only if its date is at least 168 hours before
the window end (2026-09-01T00:00Z), so every release is observed over the same
full horizon under every window.

STATISTIC: p_d = P(a follow-up release within 72 h | the release was on day
group d), and RR = p_Friday / p_Monday..Thursday, with a 95 % interval on
log RR by the usual delta method.

VERDICT:
    survived      lo(RR) > 1.10
    killed        0.90 < lo(RR) and hi(RR) < 1.10
    inconclusive  otherwise

WHAT IS DECIDABLE, computed now. se(log RR) ~ sqrt((1-p1)/(n1 p1) +
(1-p2)/(n2 p2)). Friday is about a fifth of weekday releases. At n1 = 4,000,
n2 = 16,000 and p = 0.10 the half-width of the CI is about 0.10 in log terms,
i.e. a factor of 1.11 — which is the width of the whole killed band, so at
that size the killed branch is on a knife edge and only the survived branch
is comfortable. At n1 = 20,000, n2 = 80,000, p = 0.10 the half-width is about
0.046 (a factor of 1.047) and both branches are live. THE PRE-REGISTERED
CONSEQUENCE: if the eligible Friday count comes back under 10,000, the killed
branch is not reachable and the honest verdict for a null result is
INCONCLUSIVE (floor not reached), not "killed" — decided here, before the
count is known. The floor actually achieved is computed by injection (below)
and published whatever it says.

NEGATIVE CONTROL, scored: the same RR with TUESDAY in place of Friday,
against Monday/Wednesday/Thursday. Folklore says nothing about Tuesday. If
that control's CI excludes 1 and its point estimate is outside [0.95, 1.05],
then the measurement sees weekday structure that has nothing to do with the
claim, and claim B is reported as VOID for that reason rather than scored
([[void-is-a-verdict]]).

POSITIVE CONTROL / FLOOR, by injection: take the observed rows, and for a
grid of true relative risks r in {1.05, 1.10, 1.15, 1.20, 1.30}, resample the
Friday follow-up indicator at rate min(1, r * p_Friday_observed) 400 times,
run the identical decision rule, and report the smallest r that the rule
CONFIRMS in at least 80 % of runs. That number is printed beside the verdict
whatever the verdict is ([[a-negative-result-needs-a-positive-control]]).

--------------------------------------------------------------------------
BRANCH ODDS, mine, before the data
--------------------------------------------------------------------------
    Claim A   survived 0.70   killed 0.10   inconclusive 0.20
    Claim B   survived 0.15   killed 0.50   inconclusive 0.35

I expect a real Friday dip in when people publish, and I expect the breakage
half — the half the advice actually rests on — to have nothing in it. Written
down so that whichever way it lands I cannot claim afterwards to have known.

--------------------------------------------------------------------------
REPORTED, registered here, NOT scored
--------------------------------------------------------------------------
The full weekday histogram including Saturday and Sunday; the hour-of-day
histogram; the same Friday share by calendar year 2016..2026 (is the dip
growing?); the share of releases whose follow-up came within 72 h, by day, as
levels and not only as a ratio; the yank rate by weekday (PyPI marks a release
yanked when the maintainer withdraws it — a direct breakage signal, but rare
and adopted only since 2020, so it is reported and not scored); the number of
projects and releases dropped at each filter; and the six clock offsets above.
Anything beyond this list is labelled in the entry as found after the data.
"""

import json
import math
import random

SEED = 20260916
FRAME_RANKS = 5000
N_PROJECTS = 1000
WINDOW_START = "2016-01-01T00:00:00Z"
WINDOW_END = "2026-09-01T00:00:00Z"
PRIMARY_WINDOW_H = 72
ROBUSTNESS_WINDOWS_H = (24, 168)
HORIZON_H = 168
CLOCK_OFFSETS_H = (-8, -5, 0, 2, 5.5, 8)

A_SURVIVE_HI = 0.180
A_KILL_LO = 0.190
B_SURVIVE_LO = 1.10
B_KILL_BAND = (0.90, 1.10)
B_FLOOR_MIN_FRIDAY_N = 10000
CONTROL_TOLERANCE = (0.95, 1.05)


def wilson(k, n, z=1.96):
    """95 % Wilson interval for a proportion."""
    if n == 0:
        return (float("nan"), float("nan"))
    p = k / n
    d = 1 + z * z / n
    centre = (p + z * z / (2 * n)) / d
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
    return (centre - half, centre + half)


def rr_ci(k1, n1, k2, n2, z=1.96):
    """Relative risk (group 1 over group 2) and its 95 % log interval."""
    if k1 == 0 or k2 == 0 or n1 == 0 or n2 == 0:
        return (float("nan"), float("nan"), float("nan"))
    p1, p2 = k1 / n1, k2 / n2
    rr = p1 / p2
    se = math.sqrt((1 - p1) / k1 + (1 - p2) / k2)
    return (rr, rr * math.exp(-z * se), rr * math.exp(z * se))


def verdict_a(lo, hi):
    if hi < A_SURVIVE_HI:
        return "survived"
    if lo > A_KILL_LO:
        return "killed"
    return "inconclusive"


def verdict_b(lo, hi, n_friday):
    if lo > B_SURVIVE_LO:
        return "survived"
    if B_KILL_BAND[0] < lo and hi < B_KILL_BAND[1]:
        if n_friday < B_FLOOR_MIN_FRIDAY_N:
            return "inconclusive (floor not reached)"
        return "killed"
    return "inconclusive"


def sample_projects(rows):
    """The sealed sample: 1,000 of the top 5,000 by download count."""
    frame = [r["project"] for r in rows[:FRAME_RANKS]]
    return random.Random(SEED).sample(frame, N_PROJECTS)


if __name__ == "__main__":
    rows = json.load(open("top-pypi-packages.min.json"))["rows"]
    for name in sample_projects(rows):
        print(name)
