198 lines
9.4 KiB
Python
198 lines
9.4 KiB
Python
#!/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())
|