#!/usr/bin/env python3
"""exp15 — deterministic gender transport for the memory-bank seed (owner decision #2, 16.07).

Mirrors the exp14b_reseed_promote provenance pattern: IDEMPOTENT + byte-stable + one-time
pre-snapshot + provenance log + loud self-review asserts. The `gender` field ALREADY exists in
guzhenren-seed-v2.yaml and is consumed by Go (memseed.go:35 -> memory.go:273 hash -> store;
migrate.go:183 valid values male|female|hidden|''). So this transport does three jobs:

  (1) AUDIT gender coverage of every person (type=name) record and EMIT an owner-signable
      disputed/missing list (`gender_signoff.yaml` template) — the T-gen trap subjects and the
      Q3a' state-arm need signed gender.
  (2) APPLY only owner-SIGNED additions from `gender_signoff.yaml` (src -> male|female|hidden),
      idempotently + byte-stably. n-a rows document "confirmed no gender field" (clan/place/concept)
      and are NOT mutated. No signoff file, or seed already complete -> NO-OP (snapshot hash preserved).
  (3) SELF-REVIEW by execution: build the eval-side scene-state slot from matched entries and assert
      gender surfaces for gendered persons (this is the Q3a' state mechanism; hidden -> gender-avoidant).

Vocabulary map (prompt shorthand -> DB string, migrate.go:183): m->male f->female hidden->hidden n-a->''(absent).
Discipline: seed is OUTSIDE git; population is owner's signature (approved-invariant). If signature does
not arrive before Q3a', the state-arm is honestly marked "state without gender", NOT imitated (prompt #2).

Run:  eval/.venv/bin/python eval/exp15/gender_transport.py            # audit + apply signed (default)
      eval/.venv/bin/python eval/exp15/gender_transport.py --audit    # audit only, never mutate
"""
from __future__ import annotations

import sys
from pathlib import Path

import yaml

BOOK = Path("/home/ubuntu/books/gu-zhenren")
SEED = BOOK / "guzhenren-seed-v2.yaml"
SIGNOFF = BOOK / "exp15" / "gender_signoff.yaml"          # owner-editable; src -> gender
BAK = BOOK / "exp15" / "guzhenren-seed-v2.pre-gender.yaml"  # one-time immutable "before"
LOG = BOOK / "exp15" / "gender_transport.md"
DUMP = dict(allow_unicode=True, sort_keys=False, width=200)  # SAME params that wrote the seed (byte-stable)
VALID_DB = {"male", "female", "hidden"}                    # values that mutate a record
VALID_SIGN = VALID_DB | {"n-a"}                            # n-a = documented, no field
SHORT = {"m": "male", "f": "female", "hidden": "hidden", "n-a": "n-a",
         "male": "male", "female": "female"}


def load_yaml(p: Path):
    return yaml.safe_load(p.read_text(encoding="utf-8"))


def persons(data):
    return [t for t in data["terms"] if t.get("type") == "name"]


def load_signoff():
    """Owner-signed src->gender. Absent => empty (pure audit / no additions)."""
    if not SIGNOFF.exists():
        return {}
    raw = load_yaml(SIGNOFF) or {}
    entries = raw.get("gender", raw) if isinstance(raw, dict) else {}
    out = {}
    for src, g in entries.items():
        g = str(g).strip()
        norm = SHORT.get(g, g)
        assert norm in VALID_SIGN, f"signoff bad value for {src!r}: {g!r} (allowed m/f/hidden/n-a)"
        out[src] = norm
    return out


def apply_signed(data, signoff):
    """Mutate in place from SIGNED table; return list of (src, old, new). Idempotent."""
    by_src = {t.get("src"): t for t in data["terms"]}
    applied, warn = [], []
    for src, g in signoff.items():
        if src not in by_src:
            warn.append(src)
            continue
        rec = by_src[src]
        if g == "n-a":
            # documented no-gender; assert the record indeed has no meaningful gender
            if rec.get("gender"):
                warn.append(f"{src}: signed n-a but record has gender={rec['gender']!r}")
            continue
        old = rec.get("gender")
        if old != g:
            rec["gender"] = g
            applied.append((src, old, g))
    return applied, warn


def scene_state_slot(data, chunk, chapter=15):
    """Q3a' state mechanism (eval-side): deterministic scene-state slot from matched seed entries,
    including gender (hidden -> gender-avoidant instruction). Substring match on Han keys."""
    lines = []
    for t in persons(data):
        src = t.get("src")
        if src and src in chunk and int(t.get("since_ch", 0)) <= chapter:
            dst = (t.get("dst") or "").strip()
            g = t.get("gender") or ""
            if g == "male":
                tag = "муж. (прош. вр. м.р.)"
            elif g == "female":
                tag = "жен. (прош. вр. ж.р.)"
            elif g == "hidden":
                tag = "пол скрыт до раскрытия -> гендерно-нейтральные конструкции"
            else:
                tag = "пол НЕ задан"
            lines.append(f"- {dst}: {tag}")
    return lines


def emit_signoff_template(data, missing):
    """Write a signoff template ONLY if none exists — pre-fill current gendered persons + disputed rows."""
    if SIGNOFF.exists():
        return False
    doc = ["# exp15 gender sign-off (owner) — src -> m | f | hidden | n-a",
           "# m/f = person; hidden = gender concealed until reveal (render gender-avoidant);",
           "# n-a = clan/place/concept (NO gender field). Edit values, then re-run gender_transport.py.",
           "# Rows already gendered in seed are shown for confirmation; DISPUTED rows need your call.",
           "gender:"]
    for t in persons(data):
        src, dst, g = t.get("src"), t.get("dst"), t.get("gender")
        if g in VALID_DB:
            doc.append(f"  {src}: {g}            # {dst} (already in seed; confirm)")
    for t in missing:
        doc.append(f"  {t.get('src')}: DISPUTED   # {t.get('dst')} — NEEDS YOUR CALL (person=m/f, else n-a)")
    SIGNOFF.write_text("\n".join(doc) + "\n", encoding="utf-8")
    return True


def main():
    audit_only = "--audit" in sys.argv
    data0 = load_yaml(SEED)
    ppl = persons(data0)
    gendered = [t for t in ppl if t.get("gender") in VALID_DB]
    missing = [t for t in ppl if t.get("gender") not in VALID_DB]

    signoff = load_signoff()
    template_written = emit_signoff_template(data0, missing)

    # one-time immutable "before"
    if not audit_only and not BAK.exists():
        BAK.write_text(SEED.read_text(encoding="utf-8"), encoding="utf-8")

    data = load_yaml(SEED)
    applied, warn = ([], [])
    if not audit_only and signoff:
        applied, warn = apply_signed(data, signoff)
        new_bytes = yaml.dump(data, **DUMP)
        if applied:
            SEED.write_text(new_bytes, encoding="utf-8")

    # ---- self-review: scene-state slot surfaces gender ----
    demo_chunk = "".join(t["src"] for t in gendered[:3]) + "在村中相遇。"
    slot = scene_state_slot(data, demo_chunk)

    # ---- provenance log ----
    lg = ["# exp15 — gender transport (owner decision #2, 2026-07-16)", "",
          f"Seed: `{SEED.name}` (OUTSIDE git). Pattern: exp14b_reseed_promote (idempotent, byte-stable, pre-snapshot+log).",
          f"Gender already consumed by Go: memseed.go:35 -> memory.go:273 (hashed) -> store; migrate.go:183 = male|female|hidden|''.",
          "", f"Person (type=name) records: {len(ppl)}; gendered {len(gendered)}; needing signature {len(missing)}.",
          f"Signed additions applied this run: {len(applied)} (0 = seed already complete / no signoff -> snapshot hash UNCHANGED).",
          ""]
    lg += ["## Gendered persons (already signed via approved status)", "| src | dst | gender |", "|---|---|---|"]
    for t in gendered:
        lg.append(f"| {t.get('src')} | {t.get('dst')} | {t.get('gender')} |")
    lg += ["", "## DISPUTED / missing gender — need owner signature", "| src | dst | note |", "|---|---|---|"]
    for t in missing:
        lg.append(f"| {t.get('src')} | {t.get('dst')} | {(t.get('note') or '')[:80]} |")
    if applied:
        lg += ["", "## Applied signed additions this run", "| src | old | new |", "|---|---|---|"]
        lg += [f"| {s} | {o} | {n} |" for s, o, n in applied]
    if warn:
        lg += ["", "⚠ warnings: " + "; ".join(warn)]
    lg += ["", "## Self-review — Q3a' scene-state slot (eval-side, gender surfaces)",
           f"synthetic chunk (my construct): `{demo_chunk}`", "```", *(slot or ["(empty)"]), "```"]
    if not audit_only:
        LOG.write_text("\n".join(lg), encoding="utf-8")

    # ---- console ----
    print(f"{'[AUDIT] ' if audit_only else ''}persons={len(ppl)} gendered={len(gendered)} need-signature={len(missing)} applied={len(applied)}")
    print("gendered:", ", ".join(f"{t.get('src')}={t.get('gender')}" for t in gendered))
    print("NEEDS SIGNATURE:", ", ".join(f"{t.get('src')}({t.get('dst')})" for t in missing) or "(none)")
    if template_written:
        print(f"-> wrote sign-off TEMPLATE: {SIGNOFF} (owner edits DISPUTED rows, then re-run)")
    print("-- scene-state slot (self-review) --")
    for x in (slot or ["(empty)"]):
        print("  " + x)

    # ---- loud asserts (self-review by execution) ----
    assert all(t.get("gender") in VALID_DB for t in gendered), "gendered set contains a bad value"
    # every gendered person surfaces its tag in the slot when present in chunk:
    for t in gendered[:3]:
        assert any(t.get("dst") in line for line in slot), f"state slot dropped {t.get('dst')}"
    assert not warn or audit_only, f"apply warnings: {warn}"
    print("\nOK: gender field consumed by Go, transport idempotent/byte-stable, state slot surfaces gender.")
    return 0


if __name__ == "__main__":
    sys.exit(main())
