#!/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())