"""The sealed rule for "the em dash fell on Hacker News after March 2026".

Sealed before any comment of this sample reached this server, but AFTER I saw the pattern.
The first study (research/hn-em-dash, void by its own baseline gate) showed the monthly
share of live comments holding an em dash at about 1.3 % through February 2026 and about
0.7 % from May. I noticed that by looking, which is why a fresh sample has to decide it.
Two dated events sit beside the fall: an analysis of new accounts' symbols published on 25
February 2026 (marginalia.nu/weird-ai-crap/hn/) and the guideline "Don't post generated text
or AI-edited text", on the front page on 11 March 2026 (item 47340079).

This can show that the share fell and that the first sample was not a fluke. It cannot show
why. Fewer machine-written comments, people dropping the mark, and moderators killing such
comments would all look the same here, because the item API has returned only a stub of
about 7 characters for a dead comment since 2022 and dead text cannot be read.

Sample: for each month below, 8,000 item ids drawn uniformly from that month's id range
(bounds.json of the first study) with random.Random(f"{month}-fall-20261103"); ids also in
the first study's sample are not fetched, so no comment is counted twice across the two.
Cleaning, usable comment and the U+2014 flag are `flags` in research/hn-em-dash/rule.py,
sealed as rule.hn-em-dash. The measure is on comments not dead.

Estimate. B = 2025-03..2026-02, A = 2026-04..2026-10, March 2026 left out as the month of
the change. R = mean(monthly share over A) / mean(monthly share over B). Percentile bootstrap,
10,000 draws, seed 20261104, resampling months inside each window.

Verdict, on a fall of a quarter as the smallest worth the name. A fifth was the first bar; on
synthetic data before any fetch, the seven-month window could not reach it at this sample:
  survived      upper bound below 1 and R at most 0.75;
  killed        lower bound above 0.75; the report says if the share rose;
  inconclusive  otherwise.

Void conditions:
V1 Floor. 2.8 x SE(log R) must be below -log(0.75).
V2 Planted fall. Thinning the marks in A so the expected share is min(share, 0.55 M), with M the
   mean share over B: at least 180 of 200 draws called survived. Planted null: raising A to
   max(share, M): at least 180 of 200 draws not called survived.
V3 Same season a year earlier: B' = 2024-03..2025-02, A' = 2025-04..2025-10. R' must lie in
   [0.8, 1.25].
V4 Data. Every month of the four windows must hold at least 2,000 usable live comments.

Printed for the reader, moving nothing: the same estimate for the curly apostrophe, semicolon
and spaced double hyphen; mean comment length in each window; dead comments per window.
"""

import numpy as np

SEED = 20261104
B = ("2025-03", "2026-02")
A = ("2026-04", "2026-10")
B0 = ("2024-03", "2025-02")
A0 = ("2025-04", "2025-10")
FEATURES = ("em", "curly", "en", "dbl", "semi", "delve")
BAR = 0.75


def month_range(a, b):
    ya, ma = map(int, a.split("-"))
    yb, mb = map(int, b.split("-"))
    return [f"{y}-{m:02d}" for y in range(ya, yb + 1) for m in range(1, 13)
            if (ya, ma) <= (y, m) <= (yb, mb)]


def months():
    return sorted(set(month_range(*B) + month_range(*A) + month_range(*B0) + month_range(*A0)))


def estimate(shares, b, a, draws=10000, seed=SEED):
    x = np.array([shares[m] for m in month_range(*b)], float)
    y = np.array([shares[m] for m in month_range(*a)], float)
    r = float(y.mean() / x.mean())
    if draws == 0:
        return r, None, None, None
    rng = np.random.default_rng(seed)
    out = (y[rng.integers(0, len(y), (draws, len(y)))].mean(axis=1)
           / x[rng.integers(0, len(x), (draws, len(x)))].mean(axis=1))
    lower, upper = np.percentile(out, [2.5, 97.5])
    return r, float(lower), float(upper), float(np.std(np.log(out)))


def verdict(r, lower, upper):
    if upper < 1 and r <= BAR:
        return "survived"
    if lower > BAR:
        return "killed"
    return "inconclusive"


def analyse(rows):
    """rows: dicts with month, kind, dead, chars and the FEATURES flags, ints or CSV strings."""
    ms = months()
    live = {m: {f: 0 for f in FEATURES} | {"n": 0, "chars": 0, "dead": 0} for m in ms}
    for row in rows:
        if row["kind"] != "comment" or row["month"] not in live:
            continue
        cell = live[row["month"]]
        if int(row["dead"]):
            cell["dead"] += 1
            continue
        cell["n"] += 1
        cell["chars"] += int(row["chars"])
        for f in FEATURES:
            cell[f] += int(row[f])
    share = {f: {m: live[m][f] / max(live[m]["n"], 1) for m in ms} for f in FEATURES}
    r, lower, upper, se_log = estimate(share["em"], B, A)
    r0 = estimate(share["em"], B0, A0, draws=0)[0]
    void = []
    if not 2.8 * se_log < -np.log(BAR):
        void.append(f"V1 floor: 2.8 x {se_log:.3f} is not below {-np.log(BAR):.3f}")
    if not 0.8 <= r0 <= 1.25:
        void.append(f"V3 baseline: R' = {r0:.3f} outside [0.8, 1.25]")
    thin = [m for m in ms if live[m]["n"] < 2000]
    if thin:
        void.append(f"V4 data: {len(thin)} months under 2,000: {thin[:6]}")
    rng = np.random.default_rng(SEED + 1)
    base = float(np.mean([share["em"][m] for m in month_range(*B)]))
    fall = null = 0
    for _ in range(200):
        down, up = dict(share["em"]), dict(share["em"])
        for m in month_range(*A):
            n, k = live[m]["n"], live[m]["em"]
            now = k / max(n, 1)
            keep = 0.55 * base / now if now > 0.55 * base else 1.0
            down[m] = rng.binomial(k, keep) / max(n, 1)
            flip = (base - now) * n / max(n - k, 1)
            up[m] = (k + rng.binomial(n - k, min(max(flip, 0.0), 1.0))) / max(n, 1)
        rd, lo, hi, _ = estimate(down, B, A, draws=2000, seed=int(rng.integers(1 << 31)))
        fall += verdict(rd, lo, hi) == "survived"
        ru, lo, hi, _ = estimate(up, B, A, draws=2000, seed=int(rng.integers(1 << 31)))
        null += verdict(ru, lo, hi) != "survived"
    if fall < 180:
        void.append(f"V2 planted fall: {fall} of 200")
    if null < 180:
        void.append(f"V2 planted null: {null} of 200")
    readers = {f: estimate(share[f], B, A)[:3] + estimate(share[f], B0, A0, draws=0)[:1]
               for f in ("curly", "semi", "dbl")}
    lengths = {}
    for name, w in (("B", B), ("A", A), ("B0", B0), ("A0", A0)):
        wm = month_range(*w)
        lengths[name] = {"mean_chars": sum(live[m]["chars"] for m in wm) / max(sum(live[m]["n"] for m in wm), 1),
                         "live": sum(live[m]["n"] for m in wm), "dead": sum(live[m]["dead"] for m in wm),
                         "em": sum(live[m]["em"] for m in wm)}
    return {"R": r, "lower": lower, "upper": upper, "se_log": se_log, "R_baseline": r0,
            "planted_fall_called": fall, "planted_null_held": null, "void": void,
            "verdict": "void" if void else verdict(r, lower, upper), "readers": readers,
            "windows": lengths, "share_em": share["em"], "n": {m: live[m]["n"] for m in ms}}
