# -*- coding: utf-8 -*-
"""3호 — 경기 연령 격차의 시군 간/내 분해.  원자료 18만 행 → 분석표.

⛔ 이 호는 **원자료가 호 폴더 밖**에 있는 첫 호다
   (`자료/읍면동인구_경기_2011_2025.csv` · 185,460행 · `자료/읍면동_받기.py` 가 받는다).
   그래서 **관문 C12「정제 이력」이 켜진다**(`정제.py`). 아래 정제 결정을
   코드 주석이 아니라 `analysis/정제이력.json` 에 적고, 관문이 그걸 읽는다.

★ 분석표에 «유일한 키» 열을 둔다 〔2026-08-30〕
   대장의 `raw:` 검산식은 키가 유일할 때만 뜻이 있다. 큰 자료에서 이 전제가 깨지는 것을
   실측으로 확인했으므로(시군 31개 키 전부 비유일 → 전 키 FAIL), 이 호는 처음부터
   `키` 열을 만들어 둔다.
"""
import csv, hashlib, json, math, sys
from collections import defaultdict
from pathlib import Path

HERE = Path(__file__).resolve().parent
호 = HERE.parent
매거진 = 호.parent
원자료 = 매거진 / "자료" / "읍면동인구_경기_2011_2025.csv"
# ★ 시군 코드 → 이름. 차트에 「41110」이라고 적을 수는 없다.
#   ⛔ 손으로 적지 않는다 — KOSIS 메타에서 받는다(`자료/시군이름_받기.py`).
#     31 개를 타자로 치면 그 순간부터 대조할 원본이 없어진다.
#   ⚠ 이 표는 «표시»에만 쓴다. 어떤 집계에도 들어가지 않는다(정제 규칙에 적어 뒀다).
이름표 = 매거진 / "자료" / "시군이름_경기.csv"

청년라벨 = ("20 - 24세", "25 - 29세", "30 - 34세")
노년하한 = 65
연도들 = [str(y) for y in range(2011, 2026)]
보일해 = ["2011", "2015", "2020", "2025"]


# ────────────────────────────── 읽기·정제 ──────────────────────────────
def 시군of(동코드):
    """행정동 10자리 → «기초자치단체» 5자리.

    ⛔⛔ 앞 5자리를 그대로 쓰면 «시군»이 아니라 «시군구»다 〔2026-08-30 외부 검수가 짚은 자리〕.
       KOSIS 5자리 코드에는 **일반구**가 따로 있다 — 41110 수원시 «와» 41111 장안구가
       둘 다 있고, 행정동 코드는 구가 있는 시에서 «구» 코드로 시작한다.
       그대로 자르면 시군이 31 이 아니라 **44** 가 되고, 「같은 시 안의 구끼리 차이」가
       «시군 간»으로 새어 나간다 — 이 호의 결론이 정확히 그 값이다.

    규칙: 구 코드는 시 코드에서 끝자리만 다르다(41111·41113·41115·41117 → 41110).
          그래서 10 으로 내림하면 시가 된다. 경기 5자리 60개 중 이 규칙으로
          시 코드가 안 나오는 것은 `41105 북부출장소` 하나뿐이고, 행정동이 딸리지 않는다.
    ⚠ 여주는 2013 년 «여주군 → 여주시» 승격으로 코드가 둘이다 — **41730(여주군) · 41670(여주시)**.
      같은 해에 둘이 동시에 나오지는 않으므로 연도별 집계에서는 31 이 유지된다
      (2011~2012 는 41730, 2013 부터 41670 — 실측 확인).
      ⛔ 여기 처음에 「41800 · 41830」이라고 적혀 있었다 〔2026-08-31 정정〕.
        **41800 은 연천군, 41820 은 가평군, 41830 은 양평군**이고 셋 다 전 기간 내내 있다.
        코드는 맞게 돌고 있었고 «결론»(연도마다 31)도 맞았지만 **근거로 든 코드가 틀렸다.**
        시군 이름표를 받아 눈으로 대조하다가 잡았다 — 코드를 이름 없이 적으면 이렇게 된다.
    """
    return f"{(int(동코드[:5]) // 10) * 10:05d}"


def 라벨하한(lab):
    s = lab.replace(" ", "")
    if s in ("계", "합계"):
        return None
    n = ""
    for ch in s:
        if ch.isdigit():
            n += ch
        else:
            break
    return int(n) if n else None


def 읽어정제():
    """원자료 → (지표, 규칙기록).  버린 것은 «세어서» 남긴다."""
    셀, 계, 이름 = defaultdict(dict), {}, {}
    통계 = defaultdict(int)
    with open(원자료, encoding="utf-8") as f:
        for r in csv.DictReader(f):
            통계["원자료행"] += 1
            v = (r["값"] or "").strip()
            if not v.lstrip("-").isdigit():
                통계["숫자아님"] += 1
                continue
            키, lab = (r["연도"], r["행정동코드"]), r["연령"].strip()
            이름[r["행정동코드"]] = r["행정동"]
            if 라벨하한(lab) is None:
                계[키] = int(v)          # ★ ‘계’ 는 합산에 넣지 않고 분모로만
                통계["계행"] += 1
            else:
                셀[키][lab] = int(v)
                통계["구간행"] += 1

    지표 = {}
    for 키, d in 셀.items():
        전체 = 계.get(키)
        if 전체 is None:
            통계["계없음"] += 1
            continue
        if 전체 == 0:
            # ⛔ 「사람이 없다」가 아니라 «폐지된 동»이다 — 실측: 능서면 2021
            통계["계가0"] += 1
            continue
        구간합 = sum(d.values())
        if 구간합 != 전체:
            통계["합불일치"] += 1
            continue
        지표[키] = {
            "계": 전체,
            "청년": sum(v for k, v in d.items() if k in 청년라벨),
            "노년": sum(v for k, v in d.items()
                       if (h := 라벨하한(k)) is not None and h >= 노년하한),
        }
    return 지표, 이름, 통계, 셀


def 시군이름():
    """{시군코드: 이름}.  ⚠ «표시 전용» — 어떤 집계에도 들어가지 않는다."""
    with open(이름표, encoding="utf-8") as f:
        return {r["시군코드"]: r["시군이름"] for r in csv.DictReader(f)}


# ────────────────────────────── 분해 ──────────────────────────────
def 가중분산(vs, ws):
    W = sum(ws)
    if W == 0:
        return 0.0, 0.0
    m = sum(v * w for v, w in zip(vs, ws)) / W
    return m, sum(w * (v - m) ** 2 for v, w in zip(vs, ws)) / W


def 분해(동들, 지표명, 가중="인구", 척도="원"):
    """동들 = [(코드, d)].  → dict 또는 None"""
    시군별 = defaultdict(list)
    버림 = 0
    for code, d in 동들:
        p = d[지표명] / d["계"]
        if 척도 == "로짓":
            if not (0 < p < 1):
                버림 += 1
                continue
            v = math.log(p / (1 - p))
        else:
            v = p * 100
        시군별[시군of(code)].append((v, d["계"] if 가중 == "인구" else 1))
    if not 시군별:
        return None
    vs = [v for l in 시군별.values() for v, _ in l]
    ws = [w for l in 시군별.values() for _, w in l]
    m_all, var_all = 가중분산(vs, ws)
    평균들, 무게들, 내합, W = [], [], 0.0, 0.0
    for l in 시군별.values():
        a = [v for v, _ in l]; b = [w for _, w in l]
        mg, vg = 가중분산(a, b)
        평균들.append(mg); 무게들.append(sum(b)); 내합 += sum(b) * vg; W += sum(b)
    _, 간 = 가중분산(평균들, 무게들)
    내 = 내합 / W
    return {"단위수": len(vs), "시군수": len(시군별), "평균": m_all, "전체": var_all,
            "간": 간, "내": 내, "몫": 내 / (간 + 내) * 100 if (간 + 내) else 0.0,
            "sd": math.sqrt(var_all), "잔차": abs(var_all - (간 + 내)), "버림": 버림,
            # ★ 아래 셋은 «그림이 그리는 값»이다 〔2026-08-31〕.
            #   차트 코드에서 sqrt 를 하지 않고 여기서 내어 분석표에 싣는다 —
            #   그래야 그림에 찍힌 수가 전부 분석표의 «칸»이 된다(손계산이 픽셀로 새지 않는다).
            "무게": W, "sd간": math.sqrt(간), "sd내": math.sqrt(내)}


# ══════════════════════════════════════════════════════════════════
# 그림
#   ⛔ 규칙 하나 — **그림에 찍히는 수는 전부 «분석표의 칸»이어야 한다** 〔2026-08-31〕.
#     차트 코드 안에서 sqrt·나눗셈·반올림을 새로 하지 않는다. 필요하면 `분해()` 가
#     내어 분석표에 실은 다음 여기서는 «읽기만» 한다.
#     이유: 대장(C2·C4)이 지키는 것은 «본문»이고, 그림 속 숫자는 어느 관문도 안 본다.
#     관문 밖에서 손계산을 하면 그게 제일 오래 숨는다(chartkit 이 B08 에서 겪은 자리).
# ══════════════════════════════════════════════════════════════════
def 그리기(행):
    sys.path.insert(0, str(매거진))
    import chartkit as ck
    plt = ck.setup()
    칸 = {r["키"]: r for r in 행}
    이름 = 시군이름()

    # ── 차트 1 ─────────────────────────────────────────────────────
    # ⛔ 이 그림에는 «잘못 읽힐 길»이 하나 있다 〔2026-08-31〕.
    #   31개 점이 11.3~22.4 로 11pp 를 훑으므로, 눈은 「시군끼리가 더 벌어졌다」고
    #   읽는다. 그런데 이 호가 말하는 것은 «범위»가 아니라 «인구가중 표준편차»이고,
    #   양 끝의 양평·가평은 인구가 아주 작다. 그래서 둘을 그림 안에서 막는다:
    #     ① 점 크기를 인구에 비례시킨다 — 끝의 점이 작다는 것이 보인다
    #     ② 맨 아래에 «시군 평균들의 ±1SD» 띠를 같은 축에 그린다 — 개별 시군의
    #        띠와 «직접» 견줄 수 있다. 그게 이 호가 실제로 비교한 두 값이다.
    시군행 = sorted((r for r in 행 if r["구분"] == "시군" and r["지표"] == "청년"),
                   key=lambda r: float(r["평균"]))
    도 = 칸["분해|2025|청년|인구"]
    도평균, sd간, sd내 = float(도["평균"]), float(도["sd간"]), float(도["sd내"])
    무게들 = [float(r["무게"]) for r in 시군행]
    큰것, n = max(무게들), len(시군행)
    띠색 = ck.BAND       # 같은 계열의 «폭». 색·대비 규칙은 chartkit 이 지킨다

    fig, ax = plt.subplots(figsize=(9.8, 9.6))
    for i, r in enumerate(시군행):
        m, sd = float(r["평균"]), float(r["sd"])
        ax.plot([m - sd, m + sd], [i, i], color=띠색, lw=3.4,
                solid_capstyle="butt", zorder=2)
    ax.scatter([float(r["평균"]) for r in 시군행], range(n),
               s=[26 + 250 * (w / 큰것) for w in 무게들],
               color=ck.PRIMARY, zorder=4, linewidths=0)
    ax.axvline(도평균, color=ck.ZERO, lw=1.1, ls=(0, (5, 3)), zorder=1)

    # 기준 띠 — 「시군 «사이»」를 같은 축에 얹는다
    # ⛔ 그림에 찍히는 수는 전부 «분석표의 칸»이어야 한다 〔2026-08-31 규약〕.
    #   2026-09-06 — 아래 안내 문구의 「31개 시군」이 손으로 박혀 있었다(내부 전수 감사).
    #   같은 그림의 다른 줄은 이미 칸에서 읽고 있었다. 여기서 미리 읽어 둔다.
    좁은수 = int(칸["비교|2025|청년|인구"]["단위수"])
    시군수 = int(칸["비교|2025|청년|인구"]["시군수"])
    기준y = -2.4
    ax.plot([도평균 - sd간, 도평균 + sd간], [기준y, 기준y], color=ck.HILITE,
            lw=3.4, solid_capstyle="butt", zorder=4)
    ax.scatter([도평균], [기준y], s=90, color=ck.HILITE, zorder=5, linewidths=0)
    ax.text(도평균 + sd간 + .45, 기준y,
            f"{시군수}개 시군 «평균»들의 ±1 표준편차 = {sd간:.2f}pp" + chr(10)
            + f"시군 «안»의 분산을 인구가중 합산한 표준편차 = {sd내:.2f}pp",
            color=ck.HILITE, fontsize=9.8, va="center", fontweight="bold",
            linespacing=1.6)
    ax.axhline(-1.2, color=ck.AXIS, lw=.9, zorder=1)

    ax.set_yticks(list(range(n)) + [기준y])
    ax.set_yticklabels([이름.get(r["시군"], r["시군"]) for r in 시군행]
                       + ["시군 «사이»"], fontsize=9.5)
    for t in ax.get_yticklabels()[-1:]:
        t.set_color(ck.HILITE); t.set_fontweight("bold")
    ax.set_ylim(-3.4, n + .9)
    ax.text(도평균, n + .3, f"  경기 평균 {도평균:.1f}%", color=ck.SUB,
            fontsize=9.5, va="center")
    ax.set_xlabel("읍면동 청년(20~34세) 비중 (%)", fontsize=10.5)
    ck.tidy(ax, grid="x")
    ax.set_title("합쳐서 보면, 시군 «안»의 폭이 시군 평균끼리의 폭보다 넓다",
                 fontsize=13.5, pad=16)
    ax.text(.5, -.062,
            "점 = 그 시군의 인구가중 평균(점 크기 = 인구)  ·  가로 띠 = 그 시군 «안» "
            "읍면동 비중의 ±1 표준편차  ·  2025년" + chr(10)
            + f"⚠ «모든» 시군에서 안이 더 넓다는 뜻은 아니다 — {시군수}곳 가운데 "
              f"{좁은수}곳은 시군 «사이»보다 좁다",
            transform=ax.transAxes, ha="center", va="top",
            fontsize=9.5, color=ck.SUB, linespacing=1.7)
    ck.save(fig, HERE / "chart1.png", dpi=170)

    # ── 차트 2 ─────────────────────────────────────────────────────
    # ⛔ 처음 제목을 「15년 내내 어느 잣대로 재도 절반을 넘는다」로 달았다가 지웠다
    #   〔2026-08-31〕 — **그림 자신이 그 문장을 반박한다.** 2011년 노년·동 1표가
    #   47.68% 다. 60칸 중 한 칸이지만 한 칸이면 「내내」가 아니다.
    #   ★ 그래서 예외를 «지우지 않고 논점으로» 삼는다. 그게 이 매체의 방식이다.
    색 = ck.검사한계열(ck.PRIMARY, ck.ACCENT)
    fig, ax = plt.subplots(figsize=(9.6, 5.8))
    계열 = [("청년", "인구", 색[0], "-", "청년 · 인구가중"),
           ("청년", "동", 색[0], (0, (4, 2.6)), "청년 · 동 1표"),
           ("노년", "인구", 색[1], "-", "노년 · 인구가중"),
           ("노년", "동", 색[1], (0, (4, 2.6)), "노년 · 동 1표")]
    끝값 = []
    for 지, 가, c, ls, lab in 계열:
        ys = [float(칸[f"분해|{y}|{지}|{가}"]["시군내몫"]) for y in 연도들]
        ax.plot([int(y) for y in 연도들], ys, color=c, ls=ls, lw=2.4, zorder=3)
        끝값.append([ys[-1], ys[-1], c, lab])
    끝값.sort(key=lambda x: x[0])
    for i in range(1, len(끝값)):            # 라벨끼리 겹치지 않게 최소 간격을 준다
        if 끝값[i][1] - 끝값[i - 1][1] < 1.8:
            끝값[i][1] = 끝값[i - 1][1] + 1.8
    for v, y, c, lab in 끝값:
        ax.text(2025.3, y, f" {lab}  {v:.1f}%", color=ck.글자색(c),
                fontsize=9.5, va="center", fontweight="bold")

    ax.axhline(50, color=ck.ZERO, lw=1.2, zorder=2)
    ax.text(2011, 50.6, "절반", color=ck.SUB, fontsize=9.5, va="bottom")

    예외 = float(칸["분해|2011|노년|동"]["시군내몫"])
    ax.scatter([2011], [예외], s=120, facecolor="white",
               edgecolor=ck.HILITE, linewidths=2.2, zorder=6)
    ax.annotate("60칸 가운데 «단 하나»의 예외" + chr(10)
                + f"2011년 노년 · 동 1표 {예외:.1f}%",
                xy=(2011.08, 예외), xytext=(2013.4, 46.6),
                color=ck.HILITE, fontsize=10, fontweight="bold", va="center",
                arrowprops=dict(arrowstyle="-", color=ck.HILITE, lw=1.4,
                                shrinkA=2, shrinkB=6))
    ax.set_ylim(44.2, 82)
    ax.set_xlim(2010.7, 2025.2)
    ax.set_xticks([2011, 2015, 2020, 2025])
    ax.set_ylabel("전체 분산 가운데 시군 «안»의 몫 (%)", fontsize=10.5)
    ck.tidy(ax, grid="y")
    ax.set_title("15년 · 네 잣대 가운데 절반 아래로 내려간 것은 한 칸뿐이다",
                 fontsize=13.5, pad=16)
    ck.save(fig, HERE / "chart2.png", dpi=170)
    print("[그림] chart1.png · chart2.png")


def main():
    지표, 이름, 통계, 셀 = 읽어정제()
    print(f"[정제] 원자료 {통계['원자료행']:,}행 → 분석 가능 칸 {len(지표):,}")
    for k in ("계행", "구간행", "숫자아님", "계없음", "계가0", "합불일치"):
        print(f"   {k:8s} {통계[k]:,}")

    해별 = defaultdict(list)
    for (y, c), d in 지표.items():
        해별[y].append((c, d))
    살아 = defaultdict(set)
    for (y, c) in 지표:
        살아[c].add(y)
    균형 = {c for c, ys in 살아.items() if len(ys) == len(연도들)}

    행 = []

    def 넣기(구분, y, 지, 가중, r, 로짓=None, 시군=""):
        if not r:
            return
        행.append({
            "키": f"{구분}|{y}|{지}|{가중}" + (f"|{시군}" if 시군 else ""),
            "구분": 구분, "연도": y, "지표": 지, "가중": 가중, "시군": 시군,
            "단위수": r["단위수"], "시군수": r["시군수"],
            "평균": round(r["평균"], 4), "전체분산": round(r["전체"], 4),
            "시군간": round(r["간"], 4), "시군내": round(r["내"], 4),
            "시군내몫": round(r["몫"], 4), "sd": round(r["sd"], 4),
            "cv": round(r["sd"] / r["평균"], 5) if r["평균"] else "",
            "무게": round(r["무게"], 4), "sd간": round(r["sd간"], 4),
            "sd내": round(r["sd내"], 4),
            "잔차": f"{r['잔차']:.2e}",
            "로짓분산": round(로짓["전체"], 6) if 로짓 else "",
            "로짓내몫": round(로짓["몫"], 4) if 로짓 else "",
        })

    for y in 연도들:
        동들 = 해별[y]
        for 지 in ("청년", "노년"):
            for 가중 in ("인구", "동"):
                r = 분해(동들, 지, 가중)
                lg = 분해(동들, 지, 가중, "로짓") if 가중 == "인구" else None
                넣기("분해", y, 지, 가중, r, lg)
            균동 = [(c, d) for c, d in 동들 if c in 균형]
            넣기("균형", y, 지, "인구", 분해(균동, 지, "인구"))

    # 청년 «정의»를 바꿔도 결론이 서는가 — 본문이 범위를 주장하므로 분석표에 남긴다
    정의들 = {
        "20_34": ("20 - 24세", "25 - 29세", "30 - 34세"),
        "15_34": ("15 - 19세", "20 - 24세", "25 - 29세", "30 - 34세"),
        "20_39": ("20 - 24세", "25 - 29세", "30 - 34세", "35 - 39세"),
        "25_39": ("25 - 29세", "30 - 34세", "35 - 39세"),
    }
    원래 = 청년라벨
    for 이름, 라벨 in 정의들.items():
        globals()["청년라벨"] = 라벨
        # 청년 수를 정의에 맞게 다시 센다 — 원자료 셀에서 직접
        해2 = []
        for (y, c) in 지표:
            if y != "2025":
                continue
            cell = 셀[(y, c)]
            해2.append((c, {"계": 지표[(y, c)]["계"],
                           "청년": sum(v for k, v in cell.items() if k in 라벨),
                           "노년": 지표[(y, c)]["노년"]}))
        넣기("정의민감도", "2025", "청년", "인구", 분해(해2, "청년", "인구"), None, 이름)
    globals()["청년라벨"] = 원래

    # 2025년 시군별 내부 분산 — 「어느 시군이 안에서 제일 갈렸나」
    시군묶음 = defaultdict(list)
    for c, d in 해별["2025"]:
        시군묶음[시군of(c)].append((c, d))
    for sg, lst in sorted(시군묶음.items()):
        for 지 in ("청년", "노년"):
            r = 분해(lst, 지, "인구")
            if r:
                r["시군수"] = 1
                넣기("시군", "2025", 지, "인구", r, None, sg)

    # ⛔ 「한 시군 «안»이 더 넓다」는 «합쳐서» 본 말이지 «모든 시군»이 그렇다는 말이 아니다
    #   〔2026-08-31 codex 지적 · 실측 확인〕. 몇 곳이 예외인지를 세어 분석표에 싣는다 —
    #   그래야 그림설명과 본문이 그 수를 «대장에서» 가져다 쓸 수 있다.
    도청 = 칸2 = None
    #   ⛔ 2026-09-06 — 처음엔 청년만 셌다. 그래서 한계 ⑬ 이 「노년으로는 세지 않았다」고
    #     적을 수밖에 없었는데, 내부 감사가 「셀 수 있는데 안 셌다」고 짚었다.
    #     세어 보니 노년은 4곳이다(청년 3곳) — 「면제」가 아니라 «계산»으로 답한다.
    for _지 in ("청년", "노년"):
        기준 = [r for r in 행 if r["키"] == f"분해|2025|{_지}|인구"][0]
        sd간 = float(기준["sd간"])
        좁은 = [r for r in 행 if r["구분"] == "시군" and r["지표"] == _지
                and float(r["sd"]) < sd간]
        행.append({"키": f"비교|2025|{_지}|인구", "구분": "비교", "연도": "2025",
                   "지표": _지, "가중": "인구", "시군": "",
                   "단위수": len(좁은), "시군수": 기준["시군수"],
                   "평균": "", "전체분산": "", "시군간": "", "시군내": "", "시군내몫": "",
                   "sd": "", "cv": "", "잔차": "", "로짓분산": "", "로짓내몫": "",
                   "무게": "", "sd간": "", "sd내": ""})

    # ⛔ 「인구가중이라 큰 동 몇 개가 결과를 끌 수 있다」 〔2026-09-05 codex 지적〕.
    #   그 걱정을 «값으로» 잰다. 동 j 가 「시군내」에 기여하는 몫은 정확히
    #       (인구_j / 전체인구) x (비중_j - 그 시군 평균)^2
    #   이고 **전부 더하면 「시군내」와 같다**(항등식). 실측 잔차 0.0 · 1.1e-14.
    #   ★ 그래서 이건 «새 모형»이 아니라 이미 낸 분해를 쪼갠 것이다 —
    #     차트나 본문에서 손계산하지 않고 여기서 내어 분석표에 싣는다.
    for _지 in ("청년", "노년"):
        _시군별 = defaultdict(list)
        for c, d in 해별["2025"]:
            _시군별[시군of(c)].append((c, d[_지] / d["계"] * 100, d["계"]))
        _W = sum(w for l in _시군별.values() for _, _, w in l)
        _기여 = []
        for _l in _시군별.values():
            _Wg = sum(w for _, _, w in _l)
            _mg = sum(v * w for _, v, w in _l) / _Wg
            for c, v, w in _l:
                _기여.append((w / _W * (v - _mg) ** 2, c))
        _기여.sort(reverse=True)
        _합 = sum(x[0] for x in _기여)
        # 「내」의 절반을 만드는 데 동이 몇 곳 필요한가 — 「소수가 끈다」의 직접 반증이다
        _누적, _절반수 = 0.0, 0
        for _x in _기여:
            _누적 += _x[0]
            _절반수 += 1
            if _누적 >= _합 / 2:
                break
        _기준 = [r for r in 행 if r["키"] == f"분해|2025|{_지}|인구"][0]
        # 상위 5곳을 빼고 다시 분해 — 결론이 그 다섯에 걸려 있나
        _뺄것 = {x[1] for x in _기여[:5]}
        _남은 = [(c, d) for c, d in 해별["2025"] if c not in _뺄것]
        _없이 = 분해(_남은, _지, "인구", "원")
        # ⛔ 값을 «남의 열»에 끼워 넣지 않는다 — 「평균」 칸에 「상위 1곳 몫」을 넣으면
        #   다음 사람이 평균으로 읽는다. 뜻이 다르면 열을 만든다.
        행.append({"키": f"기여|2025|{_지}|인구", "구분": "기여", "연도": "2025",
                   "지표": _지, "가중": "인구", "시군": "",
                   "단위수": len(_기여), "시군수": _기준["시군수"],
                   "평균": "", "전체분산": "", "시군간": "", "시군내": "",
                   "시군내몫": "", "sd": "", "cv": "",
                   "잔차": f"{abs(_합 - float(_기준['시군내'])):.2e}",
                   "로짓분산": "", "로짓내몫": "", "무게": "", "sd간": "", "sd내": "",
                   "상위1몫": f"{_기여[0][0] / _합 * 100:.4f}",
                   "상위5몫": f"{sum(x[0] for x in _기여[:5]) / _합 * 100:.4f}",
                   "절반동수": _절반수,
                   "상위5뺀몫": f"{_없이['몫']:.4f}"})

    # ⛔ 「60칸 가운데 «한 칸»이 절반 아래」를 사람이 세지 않는다 〔2026-09-06 내부 전수 감사〕.
    #   이 문장은 이 호가 「내내」라고 못 쓰는 «유일한» 이유이고 제목의 크기를 정한다.
    #   그런데 60 도 1 도 손으로 적혀 있었다 — 분해 행이 하나 늘거나 값이 경계를
    #   넘나들면 글만 조용히 틀린다. 세는 것은 코드가 한다.
    _칸들 = [r for r in 행 if r["구분"] == "분해"]
    _절반아래 = [r for r in _칸들 if float(r["시군내몫"]) < 50]
    행.append({"키": "문턱|2025|전체|인구", "구분": "문턱", "연도": "2025", "지표": "전체",
               "가중": "인구", "시군": "",
               "단위수": len(_절반아래), "시군수": len(_칸들),
               "평균": "", "전체분산": "", "시군간": "", "시군내": "", "시군내몫": "",
               "sd": "", "cv": "", "잔차": "", "로짓분산": "", "로짓내몫": "",
               "무게": "", "sd간": "", "sd내": ""})

    # 표본 규모 자체도 분석표에 남긴다 — 본문이 「448곳 / 60%」를 주장하므로
    # 대장이 «기계로 검산 가능한» 근거를 갖게 한다 (관문 C2 가 raw: 로 대조한다)
    행.append({"키": "표본|2025|전체|인구", "구분": "표본", "연도": "2025", "지표": "전체",
               "가중": "인구", "시군": "", "단위수": len(균형), "시군수": len(살아),
               "평균": "", "전체분산": "", "시군간": "", "시군내": "", "시군내몫": "",
               "sd": "", "cv": "", "잔차": "", "로짓분산": "", "로짓내몫": "",
               "무게": "", "sd간": "", "sd내": ""})

    열 = ["키", "구분", "연도", "지표", "가중", "시군", "단위수", "시군수", "평균",
          "전체분산", "시군간", "시군내", "시군내몫", "sd", "cv", "잔차",
          "로짓분산", "로짓내몫", "무게", "sd간", "sd내",
          # ★ 아래 넷은 «동별 기여도» 전용이다 〔2026-09-05〕 — 다른 구분에서는 빈다.
          "상위1몫", "상위5몫", "절반동수", "상위5뺀몫"]
    표 = HERE / "raw.csv"
    with open(표, "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=열)
        w.writeheader()
        w.writerows(행)
    키들 = [r["키"] for r in 행]
    assert len(키들) == len(set(키들)), "★ 키가 유일하지 않다 — raw: 검산식이 죽는다"
    print(f"[분석표] {len(행)}행 · 키 유일 확인")

    그리기(행)

    # ── 정제 이력 (관문 C12 가 읽는다) ────────────────────────────
    def 행수of(p):
        with open(p, encoding="utf-8-sig") as f:
            return max(sum(1 for _ in f) - 1, 0)

    def sha(p):
        h = hashlib.sha256()
        with open(p, "rb") as f:
            for c in iter(lambda: f.read(1 << 20), b""):
                h.update(c)
        return h.hexdigest()

    규칙 = [
        {"이름": "‘계’ 행 분리", "무엇": "연령축의 ‘계’ 행은 5세 구간 합산에서 빼고 분모로만 쓴다",
         "버린행": 0, "왜": "5세 구간과 ‘계’를 같이 더하면 합이 두 배가 된다 (ds_wiki L053)"},
        {"이름": "연령은 라벨로", "무엇": "연령 구간을 코드가 아니라 라벨 문자열로 고른다",
         "버린행": 0, "왜": "이 표는 코드가 라벨과 한 칸 어긋난 전력이 있다 (ds_wiki L008 · 065=60~64세)"},
        {"이름": "키는 코드", "무엇": "키를 (연도, 행정동코드)로 잡고 이름은 키로 쓰지 않는다",
         "버린행": 0, "왜": "같은 이름을 여러 코드가 쓰는 곳이 63개다 (정자1동·고등동·중앙동 등)"},
        {"이름": "없는 행은 채우지 않는다", "무엇": "격자의 빈칸을 0으로 채우지 않고 그 해의 표본에서 뺀다",
         "버린행": 0, "왜": "없는 행은 「인구 0」이 아니라 「그해 그 동이 없었다」이다 (ds_wiki L031)"},
        {"이름": "‘계’ 가 0 이면 «폐지»로 본다",
         "무엇": "‘계’ 가 0 인 동-연도 칸을 분석에서 뺀다", "버린행": 통계["계가0"],
         "왜": "실측 능서면 2021 — 여주시 능서면이 세종대왕면으로 바뀐 해다. 인구가 없는 것이 아니라 폐지된 것이라, 비중의 분모로 쓸 수 없다"},
        {"이름": "‘계’ 없는 칸 제외", "무엇": "5세 구간은 있는데 ‘계’ 가 없는 칸을 뺀다",
         "버린행": 통계["계없음"], "왜": "분모가 없으면 비중을 만들 수 없다"},
        {"이름": "합 불일치 제외", "무엇": "5세 구간 합 ≠ ‘계’ 인 칸을 뺀다",
         "버린행": 통계["합불일치"], "왜": "둘이 어긋나면 라벨 매핑이 깨졌다는 신호라 값을 믿을 수 없다"},
        {"이름": "숫자가 아닌 값 제외", "무엇": "값이 정수로 읽히지 않는 행을 뺀다",
         "버린행": 통계["숫자아님"], "왜": "빈칸·기호가 섞이면 합계가 조용히 틀어진다"},
        {"이름": "일반구를 시로 묶는다",
         "무엇": "행정동 코드 앞 5자리를 10으로 내림해 «기초자치단체»(시군)로 묶는다",
         "버린행": 0,
         "왜": "KOSIS 5자리에는 일반구가 따로 있어 그대로 자르면 시군이 31이 아니라 44가 된다. 같은 시 안의 구끼리 차이가 «시군 간»으로 새어 나가 이 호의 핵심 값이 틀어진다"},
        {"이름": "로짓은 0·1 을 못 받는다", "무엇": "로짓 척도에서 비중이 0 또는 1 인 동을 뺀다",
         "버린행": 0, "왜": "log(p/(1-p)) 가 발산한다. 원척도 결과는 그 동들을 그대로 쓴다"},
        {"이름": "시군 이름표는 «표시 전용»",
         "무엇": "자료/시군이름_경기.csv 는 차트 축 라벨에만 쓰고 어떤 집계에도 넣지 않는다",
         "버린행": 0,
         "왜": "코드↔이름 대응은 측정값이 아니라 코드북이다. 집계에 섞이면 이름이 바뀐 해(여주군→여주시)에 같은 곳이 둘로 갈린다"},
    ]
    이력 = {
        # ★ 원자료는 «목록»이다 〔2026-08-31〕 — 이 호는 입력이 둘이다(측정값 + 코드북).
        #   하나만 적을 수 있으면 두 번째가 선언 밖에 남는다. 관문 C12 가 목록을 받는다.
        "원자료": [
            {"경로": "자료/읍면동인구_경기_2011_2025.csv",
             "행수": 통계["원자료행"], "sha256": sha(원자료),
             "출처": "통계청 KOSIS DT_1B04005N 「행정구역(읍면동)별/5세별 주민등록인구(2011년~)」 "
                   "orgId=101 itmId=T2(총인구수) prdSe=Y 2011~2025 · 지역 prefix 41(경기)",
             "받는코드": "자료/읍면동_받기.py",
             "받은날": "2026-08-30"},
            {"경로": "자료/시군이름_경기.csv",
             "행수": 행수of(이름표), "sha256": sha(이름표),
             "출처": "통계청 KOSIS getMeta orgId=101 tblId=DT_1B04005N 축 A "
                   "— 5자리·경기(41)·끝자리 0 = 기초자치단체 32개(31 시군 + 여주군 41730)",
             "받는코드": "자료/시군이름_받기.py",
             "받은날": "2026-08-31",
             "쓰임": "차트 축 라벨 전용. 집계에 넣지 않는다"},
        ],
        # ★ 코드 지문 — 「고치고 다시 안 돌린 것」을 관문이 잡는다 〔외부 감사, codex 시나리오 D〕
        "코드": {"경로": "analysis/prep.py", "sha256": sha(Path(__file__))},
        "규칙": 규칙,
        "분석표": {"경로": "analysis/raw.csv", "행수": len(행), "sha256": sha(표)},
        "요약": {"원자료행": 통계["원자료행"], "분석표행": len(행),
               "버린행": sum(int(r["버린행"]) for r in 규칙),
               "분석가능칸": len(지표),
               "균형패널동수": len(균형)},
    }
    (HERE / "정제이력.json").write_text(
        json.dumps(이력, ensure_ascii=False, indent=1), encoding="utf-8")
    print(f"[이력] 규칙 {len(규칙)}개 · 버린 행 {이력['요약']['버린행']} · "
          f"균형 {len(균형)}동")


if __name__ == "__main__":
    main()
