analyze.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. #!/usr/bin/env python3
  2. """Pure analytical functions over the unified votes schema.
  3. No I/O. Given a list of parsed vote dicts (see parse.py) and a target
  4. member id + party, produce a metrics dict ready for rendering.
  5. Classification rules — see DOCUMENTATION.md §6.
  6. """
  7. from collections import Counter, defaultdict
  8. # Lone-wolf defection threshold (max fellow same-party defectors).
  9. # Smaller chamber => tighter threshold.
  10. LONE_WOLF_THRESHOLD = {"house": 5, "senate": 3}
  11. def _norm_vote(v):
  12. """Normalize Aye/No (procedural) to Yea/Nay."""
  13. if v in ("Yea", "Aye"): return "Yea"
  14. if v in ("Nay", "No"): return "Nay"
  15. return v # Present, Not Voting, None, ""
  16. def _majority_position(party_totals):
  17. y, n = party_totals["yea"], party_totals["nay"]
  18. if y > n: return "Yea"
  19. if n > y: return "Nay"
  20. return "Split"
  21. def classify_vote(record, member_id):
  22. """Return dict with: member_vote, member_vote_norm, r_pos, d_pos,
  23. alignment, blocked. Single-vote analysis, no aggregation."""
  24. raw = record["votes"].get(member_id)
  25. norm = _norm_vote(raw)
  26. r_pos = _majority_position(record["totals"]["R"])
  27. d_pos = _majority_position(record["totals"]["D"])
  28. if norm not in ("Yea", "Nay"):
  29. return {"member_vote": raw, "member_vote_norm": norm,
  30. "r_pos": r_pos, "d_pos": d_pos,
  31. "alignment": "N/A: " + (raw or "absent"),
  32. "blocked": None}
  33. helped_r = (r_pos != "Split" and norm == r_pos)
  34. helped_d = (d_pos != "Split" and norm == d_pos)
  35. if helped_r and helped_d: align = "Helped Both"
  36. elif helped_r: align = "Helped Republicans"
  37. elif helped_d: align = "Helped Democrats"
  38. else: align = "Helped Neither"
  39. result = (record.get("result") or "").lower()
  40. failed = any(s in result for s in ("fail", "reject", "not agreed", "not passed", "not invoked"))
  41. blocked = None
  42. if d_pos == "Yea" and failed and norm == "Nay" and r_pos != "Yea":
  43. blocked = "Democrat"
  44. if r_pos == "Yea" and failed and norm == "Nay" and d_pos != "Yea":
  45. blocked = "Republican"
  46. return {"member_vote": raw, "member_vote_norm": norm,
  47. "r_pos": r_pos, "d_pos": d_pos,
  48. "alignment": align, "blocked": blocked}
  49. def aggregate(records, member_id, member_party, chamber):
  50. """records: iterable of parsed vote dicts. Returns the metrics dict
  51. consumed by render.py."""
  52. align_counts = Counter()
  53. raw_vote_counts = Counter()
  54. blocked_counts = Counter()
  55. month_align = defaultdict(Counter)
  56. rows = []
  57. va_gop = vw_gop = va_dem = vw_dem = 0
  58. lone_wolf = 0
  59. threshold = LONE_WOLF_THRESHOLD.get(chamber, 5)
  60. yeas = nays = nv = present = voting = 0
  61. for rec in records:
  62. cls = classify_vote(rec, member_id)
  63. raw = cls["member_vote"]
  64. norm = cls["member_vote_norm"]
  65. align_counts[cls["alignment"]] += 1
  66. raw_vote_counts[raw or "Absent"] += 1
  67. if cls["blocked"]:
  68. blocked_counts[cls["blocked"]] += 1
  69. if rec.get("date"):
  70. month_align[rec["date"][:7]][cls["alignment"]] += 1
  71. # vote-distribution counters
  72. if norm == "Yea": yeas += 1; voting += 1
  73. elif norm == "Nay": nays += 1; voting += 1
  74. elif raw == "Not Voting": nv += 1
  75. elif raw == "Present": present += 1
  76. # voted with/against each party majority
  77. if norm in ("Yea","Nay"):
  78. r_pos, d_pos = cls["r_pos"], cls["d_pos"]
  79. if r_pos != "Split":
  80. if norm == r_pos: vw_gop += 1
  81. else: va_gop += 1
  82. if d_pos != "Split":
  83. if norm == d_pos: vw_dem += 1
  84. else: va_dem += 1
  85. # lone wolf vs own party
  86. own_pos = r_pos if member_party == "R" else d_pos if member_party == "D" else None
  87. if own_pos and own_pos != "Split" and norm != own_pos:
  88. own_totals = rec["totals"]["R"] if member_party == "R" else rec["totals"]["D"]
  89. defectors = own_totals["nay"] if own_pos == "Yea" else own_totals["yea"]
  90. if defectors <= threshold:
  91. lone_wolf += 1
  92. # row for the table
  93. rows.append({
  94. "y": rec.get("year") or "",
  95. "r": rec.get("num") or 0,
  96. "d": rec.get("date_raw") or rec.get("date") or "",
  97. "ln": rec.get("bill") or "",
  98. "q": rec.get("question") or "",
  99. "ds": (rec.get("desc") or "")[:90],
  100. "rs": rec.get("result") or "",
  101. "m": raw or "",
  102. "ry": rec["totals"]["R"]["yea"], "rn": rec["totals"]["R"]["nay"],
  103. "dy": rec["totals"]["D"]["yea"], "dn": rec["totals"]["D"]["nay"],
  104. "a": cls["alignment"],
  105. "b": cls["blocked"] or "",
  106. })
  107. months_sorted = sorted(month_align.keys())
  108. align_labels = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"]
  109. monthly = {lab: [month_align[m].get(lab, 0) for m in months_sorted]
  110. for lab in align_labels}
  111. total = len(rows)
  112. return {
  113. "chamber": chamber,
  114. "total": total, "voting": voting,
  115. "yeas": yeas, "nays": nays, "nv": nv, "present": present,
  116. "alignment": dict(align_counts),
  117. "member": dict(raw_vote_counts),
  118. "blocked": dict(blocked_counts),
  119. "months": months_sorted, "monthly": monthly,
  120. "rows": rows,
  121. "blocked_dem_count": blocked_counts.get("Democrat", 0),
  122. "blocked_rep_count": blocked_counts.get("Republican", 0),
  123. "lone_wolf": lone_wolf,
  124. "voted_with_gop": vw_gop, "voted_against_gop": va_gop,
  125. "voted_with_dem": vw_dem, "voted_against_dem": va_dem,
  126. "lone_wolf_threshold": threshold,
  127. }