"""D2c for lv-bronte — pin hand-verified gender onto the resolved entity map. WHY THIS EXISTS, AND WHY IT IS NOT CIRCULAR. `gender_by_proximity.py` scores each name's local pronoun mix against the corpus base rate, and on this corpus it beats the inherited resolver where it counts: 1 wrong against 5. But it still resolves `Jane` MALE (m=182 f=97), and that is not a tuning miss — it is structural. Brontë's three narrators are first-person, so their names appear almost only in DIALOGUE, spoken by other characters, surrounded by those characters' pronouns. Proximity inference is therefore blind to exactly the characters the adapter is being trained on. Hemingway's notes record the same blindness from the other side, and record that Yarros's fix (the POV chapter header) does not transfer. Brontë's editions have no such header either. So the resolver's EVALUATION (how many did it get right, unaided) and the map we actually TRAIN on are two different artefacts, and conflating them is the error to avoid. This script does not improve the resolver's score and must never be quoted as if it had. It applies ground truth that a human read out of the novels to the map that goes downstream, and it reports every field it changed so the delta is visible. Renaming `Jane` to a male name would invert the pronoun agreement of the single most important text in the corpus, 336 times. That is the cost this step buys off. """ from __future__ import annotations import argparse, json from pathlib import Path def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--entities", required=True, help="entities json from gender_by_proximity.py") ap.add_argument("--out", required=True) ap.add_argument("--pin", required=True, help="comma-separated Name=f|m|n ground truth, read from the novels. " "`n` forces NEUTRAL where a surname is genuinely shared across sexes.") a = ap.parse_args() pins: dict[str, str | None] = {} for item in (s.strip() for s in a.pin.split(",") if s.strip()): name, _, g = item.partition("=") if g not in ("f", "m", "n"): print(f"== refusing: {item!r} — gender must be f, m or n") return 2 pins[name.strip().lower()] = None if g == "n" else g ents = json.loads(Path(a.entities).read_text()) changed, already, unseen = [], [], sorted(pins) for slug, w in ents.items(): for key, e in w["entities"].items(): if key in pins: if key in unseen: unseen.remove(key) was = e.get("gender") if was != pins[key]: e["gender"] = pins[key] changed.append((slug, key, was, pins[key], e["cap"])) else: already.append((slug, key, was)) Path(a.out).write_text(json.dumps(ents, ensure_ascii=False, indent=2), encoding="utf-8") print(f" pinned {len(pins)} names · changed {len(changed)} · already correct {len(already)}") for slug, key, was, now, cap in sorted(changed, key=lambda r: -r[4]): print(f" {key:<16} {slug:<14} {str(was):>4} -> {str(now):<4} ({cap} occurrences)") if unseen: # A pin that matches nothing is a typo or a stale name, and silently # ignoring it would let the operator believe a character was fixed. print(f" ⚠ {len(unseen)} pins matched NO entity: {unseen}") return 1 return 0 if __name__ == "__main__": raise SystemExit(main())