"""D2b for BabyHemingway: resolve entity gender by MAJORITY VOTE over nearby pronouns. The honorific-and-local-window resolver inherited from the Bronte/Yarros line fails badly here. Measured on this corpus before writing a line of replacement: **397 male, 20 female** across 1,102 entity records, with Catherine Barkley, Brett Ashley, Pilar, Maria, Marita and Mary all held neutral and `Helen` and `Audrey` resolved outright WRONG. A corpus containing those characters does not have twenty women in it. Why it fails is the same mechanism Yarros exposed from the other side: Hemingway's women appear mostly inside male characters' scenes, so the pronouns nearest their names are predominantly `he`. Yarros solved its version with the POV chapter header; Hemingway's editions have no such header, so that fix does not transfer and a different signal is needed. ⭐ THE SIGNAL THAT WORKS IS VOLUME. A major character is named hundreds of times, so instead of trusting the nearest pronoun in one window, every occurrence votes and the majority wins. A single window is dominated by whoever else is in the scene; three hundred windows are dominated by the person being written about. ⚠ THE INSTRUMENT REFUSES TO WRITE UNLESS IT BEATS WHAT IT REPLACES, scored against a hand-verified control list. That is the same guard `pov_gender.py` carried, and it is the only reason to believe a replacement is an improvement rather than a different set of errors. """ from __future__ import annotations import argparse, json, re, sys from collections import Counter from pathlib import Path MALE = {"he", "him", "his", "himself"} FEMALE = {"she", "her", "hers", "herself"} WINDOW = 12 # words either side of the mention MIN_VOTES = 6 # below this the evidence is too thin to overrule a hold MARGIN = 0.60 # winning share required, else HELD neutral def load_text(corpus: Path) -> str: out = [] for f in sorted((corpus / "works").glob("*.jsonl")): for line in f.read_text(encoding="utf-8").splitlines(): out.append(json.loads(line)["text"]) return "\n".join(out) def tally_all(text: str, surfaces: set) -> dict: """One pass over the corpus for EVERY surface at once. The obvious shape -- rescan the text once per surface -- is 1,102 surfaces x 995,000 words and does not finish in any useful time. Tokenise once, walk once, and carry running prefix counts of male and female pronouns so a window costs two subtractions instead of a 25-word inner loop. """ words = [w.lower() for w in re.findall(r"[A-Za-z'’]+", text)] n = len(words) pm = [0] * (n + 1) pf = [0] * (n + 1) for i, w in enumerate(words): pm[i + 1] = pm[i] + (1 if w in MALE else 0) pf[i + 1] = pf[i] + (1 if w in FEMALE else 0) want = {s.lower(): s for s in surfaces} out = {s: [0, 0] for s in surfaces} for i, w in enumerate(words): s = want.get(w) if s is None: continue lo, hi = max(0, i - WINDOW), min(n, i + WINDOW + 1) out[s][0] += pm[hi] - pm[lo] out[s][1] += pf[hi] - pf[lo] return {k: (v[0], v[1]) for k, v in out.items()} def decide(m: int, f: int, base_m: float = 0.5): """Score a name's local pronoun mix AGAINST THE CORPUS BASE RATE, not against 50:50. ⚠ MEASURED, and it is why the first version of this was refused by its own gate: a raw majority vote scored 18 correct but FIVE wrong against the incumbent's one, and every error was female-read-as-male -- Pilar m=426 f=243, Brett m=249 f=137. Both are strongly female-associated; they merely appear in a corpus where male pronouns outnumber female ones several times over, so a bare majority is dominated by the background rate rather than by the character. The correction is to ask whether a name's neighbourhood is male-heavy RELATIVE TO the corpus, which is what `base_m` supplies. A hold stays the safe outcome: the gate counts a wrong answer as worse than no answer, because rename can leave a held entity neutral but cannot undo a man's name given to a woman. """ tot = m + f if tot < MIN_VOTES: return None base_f = 1.0 - base_m # odds of the observed mix under each hypothesis, expressed as a share after dividing # out the background. lift_m > lift_f means male-heavy beyond what the corpus explains. lift_m = (m / tot) / base_m if base_m else 0.0 lift_f = (f / tot) / base_f if base_f else 0.0 s = lift_m + lift_f if not s: return None if lift_m / s >= MARGIN: return "m" if lift_f / s >= MARGIN: return "f" return None def score(resolved: dict, truth: dict) -> tuple[int, int, int]: ok = bad = held = 0 for name, want in truth.items(): got = resolved.get(name, "__absent__") if got == "__absent__": continue if got is None: held += 1 elif got == want: ok += 1 else: bad += 1 return ok, held, bad def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("corpus") ap.add_argument("--entities", required=True) ap.add_argument("--out", required=True) ap.add_argument("--control", required=True, help="Name=g,Name=g ... hand-verified, the gate this must beat") a = ap.parse_args() truth = {} for item in a.control.split(","): n, _, g = item.partition("=") truth[n.strip()] = g.strip() ents = json.loads(Path(a.entities).read_text(encoding="utf-8")) text = load_text(Path(a.corpus)) incumbent = {} for blob in ents.values(): for v in blob["entities"].values(): incumbent.setdefault(v["surface"], v.get("gender")) surfaces = {v["surface"] for blob in ents.values() for v in blob["entities"].values()} tallies = tally_all(text, surfaces) toks = [w.lower() for w in re.findall(r"[A-Za-z'’]+", text)] gm = sum(1 for w in toks if w in MALE) gf = sum(1 for w in toks if w in FEMALE) base_m = gm / (gm + gf) print(f" corpus pronoun base rate: male {gm:,} / female {gf:,} -> base_m {base_m:.3f}") resolved, detail = {}, {} for s in sorted(surfaces): m, f = tallies[s] g = decide(m, f, base_m) resolved[s] = g detail[s] = {"gender": g, "male_votes": m, "female_votes": f} ok_i, held_i, bad_i = score(incumbent, truth) ok_n, held_n, bad_n = score(resolved, truth) print(f" incumbent (honorific/window): correct {ok_i} held {held_i} WRONG {bad_i}") print(f" proximity majority vote : correct {ok_n} held {held_n} WRONG {bad_n}") for n, want in truth.items(): if resolved.get(n) not in (want, None): print(f" ⚠ still wrong: {n} want={want} got={resolved.get(n)} " f"(m={detail[n]['male_votes']} f={detail[n]['female_votes']})") if bad_n > bad_i or (bad_n == bad_i and ok_n <= ok_i): print(" REFUSING to write: does not beat the incumbent", file=sys.stderr) return 1 counts = Counter(v["gender"] for v in detail.values()) print(f" gender distribution: {dict(counts)}") out = json.loads(Path(a.entities).read_text(encoding="utf-8")) changed = 0 for blob in out.values(): for v in blob["entities"].values(): g = resolved.get(v["surface"]) if g != v.get("gender"): changed += 1 v["gender"] = g Path(a.out).write_text(json.dumps(out, indent=2, ensure_ascii=False), encoding="utf-8") print(f" wrote {a.out} ({changed} gender fields changed)") return 0 if __name__ == "__main__": raise SystemExit(main())