textmachine/eval/exp15/gender_transport.py

198 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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