"""Fewer shootings, more assaults: the analysis behind a SparkyData article.

    py src/build_article.py fewer-shootings-more-assaults

New York recorded the fewest shootings and murders in its records in 2026, while felony
assaults stayed near their highest level since the 1990s. Which kinds of violence fell, which
did not, and where does the rise sit? Four NYPD sources and one State series answer it:
- the CompStat citywide report for the week to 4 October 2026 (card ``nypd_crime_history``):
  the current year to date against 2025, and complete years back to 1990;
- the NYPD's seven major felonies by year, 2000-2025, under the same penal-law definitions;
- the Shootings and Shooting Victims files, every incident and victim from 2006 to June 2026
  (card ``nypd_shootings``);
- felony-assault complaints by offence code and premises, and arrests by the penal-law section
  charged (card ``nypd_crime_history``);
- the State's (DCJS) county series from 1990, under the FBI's definitions (card
  ``dcjs_index_crimes``), for the murders of the 1990s.

Murders join the State's count (1990-2000) to the NYPD's (2001-2025): the two are equal in every
year 2001-2022. Felony assault is the NYPD's count throughout; the State's aggravated assault is a
broader offence and is never put on its line. Counts of events carry no sampling margin; a change
is named only when the 90% Poisson range of the ratio of two counts excludes 1
(``sparkylib.stats.count_change``, from ``rate_ratio``).

Writes results.json, viz.json, charts.json, data.csv and build.json into
output/articles/fewer_shootings_more_assaults/ and site/public/articles/fewer-shootings-more-assaults/,
and copies this script there as analysis.py. Deterministic: no sampling.
"""

from __future__ import annotations

import pandas as pd

from article_kit import Article, lines, r, strip
from nycdata import crime
from sparkylib.stats import change_size, count_change

SLUG = "fewer-shootings-more-assaults"
SEED = 20261012
COMPARE = 2019                     # the last year before the pandemic
LAST = 2025                        # the last complete year
FIRST_FILE = 2006                  # the shooting and complaint files start
JOIN = 2001                        # murders: the State's count to 2000, the NYPD's from 2001
COMPSTAT = "nypd_compstat_citywide_2026_10_04"
RELEASE = "nypd_release_q3_2026"
MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September",
          "October", "November", "December"]


def date_label(iso: str) -> str:
    d = pd.Timestamp(iso)
    return f"{d.day} {MONTHS[d.month - 1]} {d.year}"


# ------------------------------------------------------------------ the year to date


def year_to_date(A: Article) -> dict:
    """The current year from CompStat: murders, shootings and felony assaults to the latest week."""
    c = crime.compstat()
    rows = c["rows"]
    out = {"week_end": c["week_end"], "week_end_label": date_label(c["week_end"])}
    # CompStat's year-to-date column sets 1 January to the week's last day against the same dates a
    # year earlier: the shooting files reproduce its 2025 counts exactly over 1 January to 4 October
    # 2025 (a Saturday), not to the matching Sunday (outside review of 2026-10-10, R6).
    prior = str(int(c["week_end"][:4]) - 1)
    out["window"] = {"now": [c["week_end"][:4] + "-01-01", c["week_end"]],
                     "before": [prior + "-01-01", prior + c["week_end"][4:]]}
    for key, name in (("murder", "Murder"), ("shootings", "Shooting Inc."), ("victims", "Shooting Vic."),
                      ("felony_assault", "Fel. Assault"), ("robbery", "Robbery"), ("total", "TOTAL")):
        ytd = rows[name]
        out[key] = count_change(ytd["ytd"], ytd["ytd_prior"])
        out[key]["published_chg_pct"] = ytd["ytd_chg"]
    out["felony_assault"]["chg_16y_pct"] = rows["Fel. Assault"]["chg_16y"]
    out["shootings"]["chg_16y_pct"] = rows["Shooting Inc."]["chg_16y"]
    out["shootings"]["chg_33y_pct"] = rows["Shooting Inc."]["chg_33y"]      # against the same weeks of 1993
    out["murder"]["chg_33y_pct"] = rows["Murder"]["chg_33y"]
    out["history"] = {name: {str(y): int(v) for y, v in yrs.items()} for name, yrs in c["history"].items()}
    A.latest(c["week_end"], "NYPD CompStat citywide report, year to date")
    return out


# ------------------------------------------------------------------ shootings


def shooting_years() -> tuple[pd.DataFrame, dict]:
    """Incidents and victims by year (2006 to the last complete year) and in the first half of
    every year to 2026, the under-18 share of victims and the murders among them."""
    s = crime.shootings()
    v = crime.shooting_victims()
    s["year"], v["year"] = s["date"].dt.year, v["date"].dt.year
    last_day = s["date"].max()
    h1 = lambda d: d["date"].dt.month <= 6            # noqa: E731
    years = pd.DataFrame({
        "incidents": s[s["year"] <= LAST].groupby("year").size(),
        "victims": v[v["year"] <= LAST].groupby("year").size(),
        "shot_dead": v[(v["year"] <= LAST) & v["murdered"]].groupby("year").size(),
        "under_18": v[(v["year"] <= LAST) & v["age_group"].eq("<18")].groupby("year").size(),
    }).fillna(0).astype(int)
    half = pd.DataFrame({"incidents": s[h1(s)].groupby("year").size(), "victims": v[h1(v)].groupby("year").size()})
    boro = s[s["year"].isin([COMPARE, LAST])].groupby(["year", "borough"]).size().unstack(0)
    info = {"last_day": last_day.date().isoformat(), "half": half, "boro": boro,
            "ytd_2025_incidents": int(((s["date"] >= f"{LAST}-01-01") & (s["date"] <= f"{LAST}-10-04")).sum()),
            "ytd_2025_victims": int(((v["date"] >= f"{LAST}-01-01") & (v["date"] <= f"{LAST}-10-04")).sum())}
    return years, info


# ------------------------------------------------------------------ the long series


def murder_years() -> pd.Series:
    """Murders by year, 1990 to the last complete year: the State's count to 2000, the NYPD's
    from 2001 (the two agree in every year 2001-2022, checked here)."""
    d = crime.dcjs_nyc().groupby("year")["murder"].sum()
    h = crime.major_felonies()
    n = h[h["offense"].eq(crime.SEVEN[0])].set_index("year")["n"]
    both = pd.concat([d, n], axis=1, keys=["state", "nypd"]).dropna()
    gap = both.loc[JOIN:2022]
    if not gap["state"].eq(gap["nypd"]).all():
        raise AssertionError(f"State and NYPD murders differ in {list(gap.index[gap['state'] != gap['nypd']])}")
    return pd.concat([d.loc[:JOIN - 1], n.loc[JOIN:]]).astype(int)


def assault_years() -> pd.DataFrame:
    """Felony assaults by year: the NYPD's table 2000-2025, its CompStat complete years 1990, 1993
    and 1998, and, from the complaint file, the count without strangulation (its own felony
    from November 2010), 2006-2025, by the year reported."""
    h = crime.major_felonies()
    fa = h[h["offense"].eq("FELONY ASSAULT")].set_index("year")["n"]
    a = crime.felony_assaults()
    a = a[a["year"].between(FIRST_FILE, LAST)]
    by_kind = a.pivot_table(index="year", columns="kind", values="n", aggfunc="sum").fillna(0).astype(int)
    out = pd.DataFrame({"felony_assault": fa})
    out["complaints"] = by_kind.sum(axis=1)
    out["without_strangulation"] = out["complaints"] - by_kind["strangulation"]
    return out, by_kind


def assault_places() -> pd.DataFrame:
    """Felony assaults by place (home, street, shelter, subway, elsewhere) and year reported."""
    p = crime.felony_assault_premises()
    p = p[p["year"].between(FIRST_FILE, LAST)]
    return p.pivot_table(index="place", columns="year", values="n", aggfunc="sum").fillna(0).astype(int)


def assault_sections() -> pd.DataFrame:
    """Felony-assault arrests by the penal-law section charged, by year."""
    a = crime.felony_assault_arrests()
    a = a[a["year"].between(FIRST_FILE, LAST)]
    return a.pivot_table(index="section", columns="year", values="n", aggfunc="sum").fillna(0).astype(int)


# ------------------------------------------------------------------ quoted figures


def quoted_figures(A: Article) -> dict:
    """Every figure the prose quotes from a report, found in its saved copy."""
    Q = A.quotes()
    rel, cs = "nypd_release_q3_2026.html", "nypd_compstat_citywide_2026-10-04.pdf"
    return {
        "release_incidents": Q.quote(RELEASE, rel, "489 shooting incidents"),
        "release_incidents_record": Q.quote(RELEASE, rel, "553 set in 2025"),
        "release_victims": Q.quote(RELEASE, rel, "594 shooting victims"),
        "release_murders": Q.quote(RELEASE, rel, "203 murders"),
        "release_murders_record": Q.quote(RELEASE, rel, "215 set in 2017"),
        "release_felony_assault": Q.quote(RELEASE, rel, "up 0.2% (22,781 vs. 22,726)"),
        "release_major": Q.quote(RELEASE, rel, "86,023"),
        "compstat_history_assault": Q.quote(COMPSTAT, cs, "Fel. Assault 44,122 41,121 28,848 23,020 29,838"),
        "compstat_history_murder": Q.quote(COMPSTAT, cs, "Murder 2,262 1,927 629 649 309"),
        "compstat_preliminary": Q.quote(COMPSTAT, cs, "Figures are preliminary"),
        "retail_worker_felony": Q.quote("ny_s8305c_2024", "ny_s8305c_2024.pdf", "Assault on a retail worker is a class E felony"),
        "retail_worker_in_force": Q.quote("ny_s8305c_2024", "ny_s8305c_2024.pdf", "one hundred eightieth day"),
        "h1_incidents": Q.quote("nypd_release_h1_2026", "nypd_release_h1_2026.html", "322 shooting incidents"),
        "h1_victims": Q.quote("nypd_release_h1_2026", "nypd_release_h1_2026.html", "381 shooting victims"),
    }


# ------------------------------------------------------------------ main


def main(argv: list[str] | None = None) -> int:
    """Run the analysis and write every file (no options)."""
    A = Article(SLUG, __file__, seed=SEED)

    # ---- now: the year to date
    now = year_to_date(A)
    A.results["now"] = now
    for key in ("murder", "shootings", "felony_assault"):
        A.printed(f"now.{key}.now", fmt="int")
        A.printed(f"now.{key}.before", fmt="int")
    A.printed("now.murder.change_pct", change_size(now["murder"]))
    A.printed("now.shootings.change_pct", change_size(now["shootings"]))
    A.printed("now.week_end_label", now["week_end_label"])
    A.claim("now.murder", "down")
    A.claim("now.shootings", "down")
    if now["felony_assault"]["direction"] != "unclear":
        raise AssertionError("the prose says felony assaults are about the same as in 2025; the range now says otherwise")

    # ---- shootings, 2006 to 2026
    sy, info = shooting_years()
    half = info["half"]
    low_year = int(sy["incidents"].idxmin())
    if low_year != LAST:
        raise AssertionError(f"the prose says {LAST} had the fewest shootings since {FIRST_FILE}; the low is {low_year}")
    if half["incidents"].idxmin() != half.index.max():
        raise AssertionError("the prose says the first half of 2026 had the fewest shootings of any first half")
    inc = sy["incidents"]
    if int(inc.loc[COMPARE:LAST].idxmax()) != 2021:
        raise AssertionError("the prose says shootings peaked in 2021")
    A.results["shootings"] = {
        "file_last_day": info["last_day"], "first_year": FIRST_FILE,
        "years": {str(y): row.to_dict() for y, row in sy.iterrows()},
        "first_half": {str(y): {"incidents": int(row["incidents"]), "victims": int(row["victims"])} for y, row in half.iterrows()},
        "peak": {"year": int(sy["incidents"].idxmax()), "incidents": int(sy["incidents"].max())},
        "low": {"year": LAST, "incidents": int(sy.loc[LAST, "incidents"]), "previous_low": int(sy.loc[:LAST - 1, "incidents"].min()),
                "previous_low_year": int(sy.loc[:LAST - 1, "incidents"].idxmin())},
        "under_18": {str(y): {"n": int(sy.loc[y, "under_18"]), "share": r(100 * sy.loc[y, "under_18"] / sy.loc[y, "victims"])}
                     for y in (FIRST_FILE, COMPARE, LAST)},
        "under_18_change": count_change(sy.loc[LAST, "under_18"], sy.loc[COMPARE, "under_18"]),
        "bronx": count_change(info["boro"].loc["Bronx", LAST], info["boro"].loc["Bronx", COMPARE]),
        "rest": count_change(info["boro"].drop("Bronx")[LAST].sum(), info["boro"].drop("Bronx")[COMPARE].sum()),
        "to_2021": count_change(inc.loc[2021], inc.loc[COMPARE]),
        "since_2021": count_change(inc.loc[LAST], inc.loc[2021]),
        "under_18_peak": {"year": int(sy.loc[COMPARE:LAST, "under_18"].idxmax()),
                          "n": int(sy.loc[COMPARE:LAST, "under_18"].max())},
        "victim_rows": crime.shooting_victims().attrs,
    }
    sh = A.results["shootings"]
    A.printed(f"shootings.years.{LAST}.incidents", fmt="int")
    A.printed("shootings.low.previous_low", fmt="int")
    A.printed("shootings.peak.incidents", fmt="int")
    A.printed(f"shootings.under_18.{COMPARE}.n", fmt="int")
    A.printed(f"shootings.under_18.{LAST}.n", fmt="int")
    A.printed(f"shootings.under_18.{LAST}.share", f"{sh['under_18'][str(LAST)]['share']:.0f}%")
    A.printed(f"shootings.under_18.{COMPARE}.share", f"{sh['under_18'][str(COMPARE)]['share']:.0f}%")
    A.claim("shootings.under_18_change", "up")
    A.claim("shootings.rest", "down")
    A.claim("shootings.to_2021", "up")
    A.claim("shootings.since_2021", "down")
    A.printed("shootings.under_18_peak.n", fmt="int")
    A.printed("shootings.rest.change_pct", change_size(sh["rest"]))
    A.printed("shootings.bronx.now", fmt="int")
    A.printed("shootings.bronx.before", fmt="int")
    A.reproduce(f"Shooting incidents, 1 January to 4 October {LAST}", published=now["shootings"]["before"],
                published_moe90=None, reproduced=info["ytd_2025_incidents"], kind="count", tolerance=0)
    A.reproduce(f"Shooting victims, 1 January to 4 October {LAST}", published=now["victims"]["before"],
                published_moe90=None, reproduced=info["ytd_2025_victims"], kind="count", tolerance=0)

    # ---- murders, 1990 to 2025
    m = murder_years()
    state = crime.dcjs_nyc().groupby("year")["murder"].sum()
    nypd = crime.major_felonies().query("offense == @crime.SEVEN[0]").set_index("year")["n"]
    gun = sy["shot_dead"]
    other = (m.loc[FIRST_FILE:LAST] - gun).astype(int)
    A.results["murders"] = {
        "years": {str(y): int(v) for y, v in m.items()}, "join": JOIN,
        "first": {"year": int(m.index[0]), "n": int(m.iloc[0])}, "last": {"year": LAST, "n": int(m.loc[LAST])},
        "low": {"year": int(m.idxmin()), "n": int(m.min())},
        "fall_since_first_pct": r(100 * (1 - m.loc[LAST] / m.iloc[0])),
        "by_gun": {str(y): int(v) for y, v in gun.items()}, "other": {str(y): int(v) for y, v in other.items()},
        "gun_share": {str(y): r(100 * gun.loc[y] / m.loc[y]) for y in (FIRST_FILE, COMPARE, LAST)},
        "other_range_2017_2025": [int(other.loc[2017:LAST].min()), int(other.loc[2017:LAST].max())],
        "state_vs_nypd": {str(y): {"state": int(state.loc[y]), "nypd": int(nypd.loc[y])}
                          for y in sorted(set(state.index) & set(nypd.index)) if y >= 2023},
    }
    mu = A.results["murders"]
    A.printed("murders.first.n", fmt="int")
    A.printed("murders.last.n", fmt="int")
    A.printed("murders.low.n", fmt="int")
    A.printed("murders.fall_since_first_pct", f"{mu['fall_since_first_pct']:.0f}%")
    A.printed(f"murders.gun_share.{FIRST_FILE}", f"{mu['gun_share'][str(FIRST_FILE)]:.0f}%")
    A.printed(f"murders.gun_share.{LAST}", f"{mu['gun_share'][str(LAST)]:.0f}%")
    A.printed(f"murders.by_gun.{LAST}", fmt="int")
    A.long_view(int(m.index[0]), LAST, "Murders: DCJS county series 1990-2000, NYPD seven major felonies 2001-2025")

    # ---- felony assaults
    ay, by_kind = assault_years()
    hist = now["history"]["Fel. Assault"]
    fa = ay["felony_assault"]
    low_year = int(fa.idxmin())
    above_1998 = int(fa.loc[LAST]) > hist["1998"]
    if not above_1998 or fa.idxmax() != LAST:
        raise AssertionError("the prose says 2025's felony assaults were the most since the 1990s")
    if fa.idxmin() != 2008:
        raise AssertionError("the prose says felony assaults bottomed out in 2008")
    state_agg = crime.dcjs_nyc().groupby("year")["aggravated_assault"].sum().loc[2000:2022]
    state_ratio = (state_agg / fa.loc[2000:2022]).round(2)
    rise = by_kind.loc[LAST] - by_kind.loc[COMPARE]
    A.results["assaults"] = {
        "years": {str(y): int(v) for y, v in fa.items()},
        "compstat_years": {y: n for y, n in hist.items()},
        "low": {"year": low_year, "n": int(fa.min())},
        "last": int(fa.loc[LAST]), "compare": int(fa.loc[COMPARE]),
        "since_compare": count_change(fa.loc[LAST], fa.loc[COMPARE]),
        "since_low": count_change(fa.loc[LAST], fa.min()),
        "complaints_by_report_year": {str(y): int(v) for y, v in ay["complaints"].dropna().items()},
        "without_strangulation": {str(y): int(v) for y, v in ay["without_strangulation"].dropna().items()},
        "without_strangulation_since_compare": count_change(ay.loc[LAST, "without_strangulation"], ay.loc[COMPARE, "without_strangulation"]),
        "by_kind": {kind: {str(COMPARE): int(by_kind.loc[COMPARE, kind]), str(LAST): int(by_kind.loc[LAST, kind]),
                           "rise": int(rise[kind]), "share_of_rise": r(100 * rise[kind] / rise.sum())}
                    for kind in by_kind.columns},
        "rise_total": int(rise.sum()),
        "state_aggravated_ratio_2000_2022": [float(state_ratio.min()), float(state_ratio.max())],
        "strangulation_first_month": "2010-11",
        # A shooting that wounds is at most one felony assault (some are charged as attempted
        # murder), so wounded victims over all felony assaults bounds the share that are shootings.
        "shooting_share_max": r(100 * (sy.loc[LAST, "victims"] - sy.loc[LAST, "shot_dead"]) / fa.loc[LAST]),
    }
    # Leaving out strangulation, which was not a felony of its own before November 2010: 2025 without
    # it is set against the NYPD's table for 2000-2005 and the complaint counts without strangulation
    # for 2006-2024. Other widenings (assaults on people 65 or over, on retail workers) sit inside
    # codes that cannot be separated, so this is not a constant definition, and the prose says so.
    without = ay["without_strangulation"].dropna()
    constant = pd.concat([fa.loc[2000:FIRST_FILE - 1], without.loc[FIRST_FILE:LAST - 1]])
    beaten = constant[constant >= without.loc[LAST]]
    if list(beaten.index) != [2000]:
        raise AssertionError(f"without strangulation, {LAST} is not the most since 2000: {dict(beaten)}")
    A.results["assaults"]["without_strangulation_vs_2000"] = {
        "last": int(without.loc[LAST]), "year_2000": int(fa.loc[2000]),
        "next_highest": {"year": int(constant.drop(2000).idxmax()), "n": int(constant.drop(2000).max())}}
    A.printed("assaults.without_strangulation_vs_2000.last", fmt="int")
    A.printed("assaults.without_strangulation_vs_2000.year_2000", fmt="int")
    first_strang = crime.felony_assaults().query("kind == 'strangulation' and n >= 20")
    a_month = first_strang.sort_values(["year", "month"]).iloc[0]
    if f"{a_month['year']}-{a_month['month']:02d}" != A.results["assaults"]["strangulation_first_month"]:
        raise AssertionError("strangulation's first full month of complaints moved")
    asl = A.results["assaults"]
    A.printed("assaults.last", fmt="int")
    A.printed("assaults.compare", fmt="int")
    A.printed("assaults.low.n", fmt="int")
    A.printed("assaults.compstat_years.1998", fmt="int")
    A.printed("assaults.compstat_years.1990", fmt="int")
    A.printed("assaults.since_compare.change_pct", change_size(asl["since_compare"]))
    A.printed("assaults.since_compare.lo_pct", fmt="pct0")
    A.printed("assaults.since_compare.hi_pct", fmt="pct0")
    A.printed("assaults.without_strangulation_since_compare.change_pct", change_size(asl["without_strangulation_since_compare"]))
    A.printed("assaults.by_kind.assault.share_of_rise", f"{asl['by_kind']['assault']['share_of_rise']:.0f}%")
    A.printed(f"assaults.by_kind.strangulation.{LAST}", fmt="int")
    A.printed("assaults.shooting_share_max", f"{asl['shooting_share_max']:.1f}%")
    A.claim("assaults.since_compare", "up")
    A.claim("assaults.without_strangulation_since_compare", "up")
    A.printed("assaults.rise_total", fmt="int")
    kinds = ["assault", "strangulation", "police or peace officer", "other public employee"]
    asl["kinds_since_compare"] = {kd: count_change(by_kind.loc[LAST, kd], by_kind.loc[COMPARE, kd]) for kd in kinds}
    if any(c["direction"] != "up" for c in asl["kinds_since_compare"].values()):
        raise AssertionError("the prose says every offence group rose")
    asl["since_2020"] = count_change(fa.loc[LAST], fa.loc[2020])
    A.claim("assaults.since_2020", "up")
    asl["compstat_vs_table_2001"] = {"compstat": int(hist["2001"]), "table": int(fa.loc[2001])}
    A.reproduce(f"Felony assaults, {LAST}, complaints by the year reported", published=asl["last"], published_moe90=None,
                reproduced=asl["complaints_by_report_year"][str(LAST)], kind="count", tolerance=10)
    A.results["reproduction_source"] = ("NYPD CompStat citywide, week to 4 October 2026 (its 2025 year-to-date column), "
                                        "and the NYPD's yearly table of the seven major felonies")
    A.results["reproduction_note"] = ("Administrative counts, compared within a stated tolerance, not a sampling margin: "
                                      "exactly (tolerance 0) for the shooting files against CompStat, and within 10 "
                                      "complaints for felony assaults counted by the year reported against the yearly table.")

    # ---- where: homes, streets, shelters
    pl = assault_places()
    share = (100 * pl / pl.sum()).round(1)
    # Shelters have a premises code only from 2018: before it their assaults sit under other
    # places, so those years are not coded, not zero (outside review of 2026-10-10, R8).
    shelter_from = int(pl.columns[(pl.loc["shelter"] > 0).to_numpy()].min())
    coded = lambda place, y: place != "shelter" or y >= shelter_from      # noqa: E731
    A.results["places"] = {
        place: {str(y): ({"n": int(pl.loc[place, y]), "share": r(share.loc[place, y])} if coded(place, y)
                         else {"n": None, "share": None, "note": f"no shelter code before {shelter_from}"})
                for y in (FIRST_FILE, COMPARE, LAST)}
        for place in pl.index}
    A.results["places_shelter_coded_from"] = shelter_from
    for place in ("home", "street"):
        A.results["places"][place]["since_compare"] = count_change(pl.loc[place, LAST], pl.loc[place, COMPARE])
    pls = A.results["places"]
    A.printed(f"places.home.{FIRST_FILE}.share", f"{pls['home'][str(FIRST_FILE)]['share']:.0f}%")
    A.printed(f"places.home.{COMPARE}.share", f"{pls['home'][str(COMPARE)]['share']:.0f}%")
    A.printed(f"places.home.{LAST}.share", f"{pls['home'][str(LAST)]['share']:.0f}%")
    A.printed(f"places.shelter.{COMPARE}.n", fmt="int")
    A.printed(f"places.shelter.{LAST}.n", fmt="int")
    A.printed("places.street.since_compare.change_pct", change_size(pls["street"]["since_compare"]))
    A.printed("places.home.since_compare.change_pct", change_size(pls["home"]["since_compare"]))
    A.claim("places.street.since_compare", "up")
    A.claim("places.home.since_compare", "up")
    pl_rise = {p: count_change(pl.loc[p, LAST], pl.loc[p, COMPARE]) for p in pl.index if pl.loc[p, COMPARE] > 0}
    if any(c["direction"] != "up" for c in pl_rise.values()):
        raise AssertionError("the heading says the rise reaches every kind of place")
    A.results["places_since_compare"] = pl_rise

    # ---- every borough, misdemeanours, rates per resident, shelters
    bo = crime.felony_assault_boroughs().pivot_table(index="borough", columns="year", values="n", aggfunc="sum")
    A.results["boroughs"] = {b: count_change(bo.loc[b, LAST], bo.loc[b, COMPARE]) for b in bo.index}
    if any(c["direction"] != "up" for c in A.results["boroughs"].values()):
        raise AssertionError("the prose says felony assaults rose in every borough")
    for b in bo.index:
        A.printed(f"boroughs.{b}.change_pct", change_size(A.results["boroughs"][b]))
    A.results["boroughs_unassigned"] = {str(y): int(by_kind.loc[y].sum() - bo[y].sum()) for y in (COMPARE, LAST)}
    prem = crime.felony_assault_premises()
    A.results["places_unrecorded"] = {str(y): int(prem.loc[prem["year"].eq(y) & (prem["premises"].isna() | prem["premises"].eq("(null)")), "n"].sum())
                                      for y in (COMPARE, LAST)}
    mis = crime.misdemeanor_assaults()
    A.results["misdemeanor"] = {str(y): int(mis.loc[y]) for y in (FIRST_FILE, COMPARE, LAST)}
    A.results["misdemeanor"]["since_compare"] = count_change(mis.loc[LAST], mis.loc[COMPARE])
    A.printed("misdemeanor.since_compare.change_pct", change_size(A.results["misdemeanor"]["since_compare"]))
    A.claim("misdemeanor.since_compare", "up")
    pop = crime.population()
    pop_last = int(pop.index.max())
    A.results["rates"] = {"population_last_year": pop_last,
                          "murder": {str(y): r(1e5 * m.loc[y] / pop.loc[y]) for y in (int(m.index[0]), pop_last)},
                          "felony_assault": {str(y): r(1e5 * fa.loc[y] / pop.loc[y], 0) for y in (2000, COMPARE, pop_last)},
                          "population": {str(y): int(pop.loc[y]) for y in (COMPARE, pop_last)},
                          "population_change_pct": r(100 * (pop.loc[pop_last] / pop.loc[COMPARE] - 1), 2)}
    # The State's populations mix Census estimate vintages (2021 sits above both neighbours), so the
    # rate's rise is quoted only because the population barely moved (outside review of 2026-10-10, R9).
    if abs(A.results["rates"]["population_change_pct"]) >= 1:
        raise AssertionError("the prose says the State's population for the last year is within 1% of 2019's")
    A.printed(f"rates.murder.{int(m.index[0])}", f"{A.results['rates']['murder'][str(int(m.index[0]))]:.1f}")
    A.printed(f"rates.murder.{pop_last}", f"{A.results['rates']['murder'][str(pop_last)]:.1f}")
    A.printed("rates.felony_assault.2000", fmt="int")
    A.printed(f"rates.felony_assault.{COMPARE}", fmt="int")
    A.printed(f"rates.felony_assault.{pop_last}", fmt="int")
    from nycdata import city
    sc = city.shelter()
    sc_year = sc.groupby(sc["date"].dt.year)["Total Individuals in Shelter"].mean()
    A.results["shelter_census"] = {"first_year": int(sc_year.index.min()), "first_month": sc["date"].min().strftime("%Y-%m"),
                                   str(int(sc_year.index.min())): r(sc_year.iloc[0], -3), str(LAST): r(sc_year.loc[LAST], -3)}
    A.printed(f"shelter_census.{int(sc_year.index.min())}", fmt="int")
    A.printed(f"shelter_census.{LAST}", fmt="int")
    A.results["index_2019"] = {"felony_assault": r(100 * fa.loc[LAST] / fa.loc[COMPARE], 0),
                               "shootings": r(100 * sy.loc[LAST, "incidents"] / sy.loc[COMPARE, "incidents"], 0),
                               "murders": r(100 * m.loc[LAST] / m.loc[COMPARE], 0)}
    A.results["shootings"]["under_18_first_half_2026"] = int(
        crime.shooting_victims().query("date.dt.year == 2026 and date.dt.month <= 6 and age_group == '<18'").shape[0])
    A.results["shootings"]["first_half_ties"] = {str(y): int(half.loc[y, "incidents"]) for y in (2018, LAST)}
    A.printed("now.shootings.chg_33y_pct", f"{abs(now['shootings']['chg_33y_pct']):.0f}%")

    # ---- arrests by section
    sec = assault_sections()
    A.results["arrests"] = {s: {str(y): int(sec.loc[s, y]) for y in (FIRST_FILE, COMPARE, LAST)} for s in sec.index}
    A.results["arrests"]["first_years"] = {s: int(sec.columns[(sec.loc[s] > 0).to_numpy()].min()) for s in sec.index}
    A.results["arrests"]["total"] = count_change(sec[LAST].sum(), sec[COMPARE].sum())
    A.printed("arrests.total.now", fmt="int")
    A.printed("arrests.total.before", fmt="int")
    w = "with a weapon or dangerous instrument"
    A.printed(f"arrests.{w}.{COMPARE}", fmt="int")
    A.printed(f"arrests.{w}.{LAST}", fmt="int")
    A.printed(f"arrests.on a retail worker.{LAST}", fmt="int")
    A.results["quotes"] = quoted_figures(A)
    import re
    got = re.search(r"up ([\d.]+)% \(([\d,]+) vs\. ([\d,]+)\)", A.results["quotes"]["release_felony_assault"]["quote"])
    rel_now, rel_before = (int(got.group(i).replace(",", "")) for i in (2, 3))
    A.results["release"] = {"felony_assault": {"now": rel_now, "before": rel_before,
                                               "change_pct": r(100 * (rel_now / rel_before - 1), 1)}}
    if f"{A.results['release']['felony_assault']['change_pct']:.1f}" != got.group(1):
        raise AssertionError("the release's percentage does not follow from its own counts")
    A.printed("release.felony_assault.change_pct", f"{A.results['release']['felony_assault']['change_pct']:.1f}%")

    # ---- data.csv: every series the charts draw
    rows = []
    for y, v in m.items():
        rows.append({"series": "murders", "year": int(y), "value": int(v),
                     "source": "NYS DCJS index crimes" if y < JOIN else "NYPD seven major felonies"})
    for y in (1990, 1993, 1998):
        rows.append({"series": "felony_assaults", "year": y, "value": int(hist[str(y)]),
                     "source": "NYPD CompStat historical perspective"})
    for y, v in fa.items():
        rows.append({"series": "felony_assaults", "year": int(y), "value": int(v), "source": "NYPD seven major felonies"})
    for y, v in ay["without_strangulation"].dropna().items():
        rows.append({"series": "felony_assaults_without_strangulation", "year": int(y), "value": int(v),
                     "source": "NYPD complaint data, by year reported"})
    for y, row in sy.iterrows():
        for col in ("incidents", "victims", "shot_dead", "under_18"):
            rows.append({"series": f"shooting_{col}", "year": int(y), "value": int(row[col]),
                         "source": "NYPD Shootings and Shooting Victims"})
    for y, row in half.iterrows():
        rows.append({"series": "shooting_incidents_first_half", "year": int(y), "value": int(row["incidents"]),
                     "source": "NYPD Shootings"})
    for place in pl.index:
        for y in pl.columns:
            if not coded(place, y):
                continue
            rows.append({"series": f"felony_assaults_{place}", "year": int(y), "value": int(pl.loc[place, y]),
                         "source": "NYPD complaint data, by year reported"})
    data = pd.DataFrame(rows)

    # ---- charts
    hy = [str(y) for y in half.index]
    A.chart("first_halves", lines(
        title=f"The first half of {half.index.max()} had the fewest shootings in the NYPD's open records",
        subtitle=f"Shooting incidents, January to June, {half.index.min()} to {half.index.max()}",
        x=[[y, y] for y in hy], series=[["incidents", "Shooting incidents", "Shootings"]],
        cells={"": {"incidents": {y: int(half.loc[int(y), "incidents"]) for y in hy}}},
        format="num", axis="Shooting incidents, January to June",
        # every fifth year and the last, leaving out a fifth year too close to the last to label
        x_ticks=[y for y in hy if (int(y) % 5 == 0 and int(hy[-1]) - int(y) >= 3) or y == hy[-1]],
        note="An incident is a shooting in which a bullet struck someone, counted once however many people were hit. "
             "The NYPD's files begin in 2006; the department says its records go back further.",
        provenance="NYPD Shootings (2006-Present), NYC Open Data"))
    yrs = list(range(FIRST_FILE, LAST + 1))
    A.chart("three_counts", {
        "kind": "index", "title": "Felony assaults pulled away from shootings and murders",
        "subtitle": f"Each count against its own level in the base year, {FIRST_FILE} to {LAST}",
        "x": [str(y) for y in yrs], "bases": [str(FIRST_FILE), str(COMPARE)], "base": str(COMPARE),
        "series": [{"key": "assault", "label": "Felony assaults", "short": "Felony assaults",
                    "values": [int(fa.loc[y]) for y in yrs], "raw_unit": "complaints"},
                   {"key": "shootings", "label": "Shooting incidents", "short": "Shootings",
                    "values": [int(sy.loc[y, "incidents"]) for y in yrs], "raw_unit": "incidents"},
                   {"key": "murders", "label": "Murders", "short": "Murders",
                    "values": [int(m.loc[y]) for y in yrs], "raw_unit": "murders"}],
        "format": "count", "emphasis": "assault", "what": "count",
        "note": "Each line divides a year's count by its count in the base year, so 150 means half as many again. "
                "Felony assaults and murders are the NYPD's yearly counts under New York's penal law; shootings are "
                "incidents in which a bullet struck someone.",
        "provenance": "NYPD seven major felonies 2000-2025; NYPD Shootings (2006-Present)"})
    my = list(m.index)
    A.chart("murders_long", lines(
        title=f"Murders fell by {mu['fall_since_first_pct']:.0f}% from {my[0]} to {LAST}",
        subtitle=f"Murders and non-negligent manslaughters a year, New York City, {my[0]} to {LAST}",
        x=[[str(y), str(y)] for y in my], series=[["murders", "Murders", "Murders"]],
        cells={"": {"murders": {str(y): int(m.loc[y]) for y in my}}},
        format="num", axis="Murders a year", x_ticks=[str(y) for y in my if y % 5 == 0 or y == LAST],
        note=f"The State's count (DCJS, FBI definitions) to {JOIN - 1}, the NYPD's from {JOIN}: the two are equal in every "
             f"year from {JOIN} to 2022. The year {now['week_end'][:4]} is not complete and is not drawn.",
        provenance="NYS DCJS Index Crimes by County, 1990-2000; NYPD seven major felonies, 2001-2025"))
    ax = list(range(int(min(hist, key=int)), LAST + 1))      # every year, so the line breaks at the unpublished ones
    A.chart("assaults_long", lines(
        title="Felony assaults rose to their highest in a quarter-century",
        subtitle=f"Felony assaults a year under New York's penal law, {ax[0]} to {LAST}",
        x=[[str(y), str(y)] for y in ax],
        series=[["all", "Felony assaults", "All"], ["without", "Without strangulation", "Without strangulation"]],
        cells={"": {"all": {str(y): int(hist[str(y)]) if y < 2000 else int(fa.loc[y]) for y in ax
                            if y >= 2000 or str(y) in hist},
                    "without": {str(y): int(ay.loc[y, "without_strangulation"]) for y in ax
                                if y in ay.index and pd.notna(ay.loc[y, "without_strangulation"])}}},
        format="num", axis="Felony assaults a year", x_ticks=[str(y) for y in ax if y % 5 == 0 or y == LAST],
        labels="legend",
        note="The NYPD's yearly table from 2000; for 1990, 1993 and 1998, the complete-year figures in its weekly report, "
             f"which can differ from the table (for 2001: {int(hist['2001']):,} against {int(fa.loc[2001]):,}). The years between "
             "are not published. Strangulation became its own felony in November 2010; the second line leaves it out, "
             "from the complaint file counted by the year reported, which runs from 2006.",
        provenance="NYPD CompStat historical perspective; NYPD seven major felonies 2000-2025; NYPD complaint data"))
    places = ["home", "street", "shelter", "subway", "elsewhere"]
    names = {"home": "Homes (apartments, houses, public housing)", "street": "The street", "shelter": "Homeless shelters",
             "subway": "The subway", "elsewhere": "Everywhere else"}
    py_ = [FIRST_FILE, COMPARE, LAST]
    A.chart("assault_places", strip(
        title="Felony assaults rose in homes and on the street alike",
        subtitle=f"Felony assaults reported, by where they happened, {FIRST_FILE}, {COMPARE} and {LAST}",
        x=[[str(y), str(y)] for y in py_], series=[[p, names[p], names[p]] for p in places],
        cells={"": {p: {str(y): int(pl.loc[p, y]) for y in py_ if coded(p, y)} for p in places}},
        format="num", axis="Felony assaults reported", order="series", moe_label="count",
        note=f"The place the complaint names. Homeless shelters have a code of their own only from {shelter_from}; before "
             f"that their assaults are counted under other places, so {FIRST_FILE} has no shelter dot.",
        provenance="NYPD Complaint Data Historic, NYC Open Data"))

    A.write(data=data)
    print(f"{SLUG}: wrote {A.out}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
