| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134 |
- #!/usr/bin/env python3
- """Pure analytical functions over the unified votes schema.
- No I/O. Given a list of parsed vote dicts (see parse.py) and a target
- member id + party, produce a metrics dict ready for rendering.
- Classification rules — see DOCUMENTATION.md §6.
- """
- from collections import Counter, defaultdict
- # Lone-wolf defection threshold (max fellow same-party defectors).
- # Smaller chamber => tighter threshold.
- LONE_WOLF_THRESHOLD = {"house": 5, "senate": 3}
- def _norm_vote(v):
- """Normalize Aye/No (procedural) to Yea/Nay."""
- if v in ("Yea", "Aye"): return "Yea"
- if v in ("Nay", "No"): return "Nay"
- return v # Present, Not Voting, None, ""
- def _majority_position(party_totals):
- y, n = party_totals["yea"], party_totals["nay"]
- if y > n: return "Yea"
- if n > y: return "Nay"
- return "Split"
- def classify_vote(record, member_id):
- """Return dict with: member_vote, member_vote_norm, r_pos, d_pos,
- alignment, blocked. Single-vote analysis, no aggregation."""
- raw = record["votes"].get(member_id)
- norm = _norm_vote(raw)
- r_pos = _majority_position(record["totals"]["R"])
- d_pos = _majority_position(record["totals"]["D"])
- if norm not in ("Yea", "Nay"):
- return {"member_vote": raw, "member_vote_norm": norm,
- "r_pos": r_pos, "d_pos": d_pos,
- "alignment": "N/A: " + (raw or "absent"),
- "blocked": None}
- helped_r = (r_pos != "Split" and norm == r_pos)
- helped_d = (d_pos != "Split" and norm == d_pos)
- if helped_r and helped_d: align = "Helped Both"
- elif helped_r: align = "Helped Republicans"
- elif helped_d: align = "Helped Democrats"
- else: align = "Helped Neither"
- result = (record.get("result") or "").lower()
- failed = any(s in result for s in ("fail", "reject", "not agreed", "not passed", "not invoked"))
- blocked = None
- if d_pos == "Yea" and failed and norm == "Nay" and r_pos != "Yea":
- blocked = "Democrat"
- if r_pos == "Yea" and failed and norm == "Nay" and d_pos != "Yea":
- blocked = "Republican"
- return {"member_vote": raw, "member_vote_norm": norm,
- "r_pos": r_pos, "d_pos": d_pos,
- "alignment": align, "blocked": blocked}
- def aggregate(records, member_id, member_party, chamber):
- """records: iterable of parsed vote dicts. Returns the metrics dict
- consumed by render.py."""
- align_counts = Counter()
- raw_vote_counts = Counter()
- blocked_counts = Counter()
- month_align = defaultdict(Counter)
- rows = []
- va_gop = vw_gop = va_dem = vw_dem = 0
- lone_wolf = 0
- threshold = LONE_WOLF_THRESHOLD.get(chamber, 5)
- yeas = nays = nv = present = voting = 0
- for rec in records:
- cls = classify_vote(rec, member_id)
- raw = cls["member_vote"]
- norm = cls["member_vote_norm"]
- align_counts[cls["alignment"]] += 1
- raw_vote_counts[raw or "Absent"] += 1
- if cls["blocked"]:
- blocked_counts[cls["blocked"]] += 1
- if rec.get("date"):
- month_align[rec["date"][:7]][cls["alignment"]] += 1
- # vote-distribution counters
- if norm == "Yea": yeas += 1; voting += 1
- elif norm == "Nay": nays += 1; voting += 1
- elif raw == "Not Voting": nv += 1
- elif raw == "Present": present += 1
- # voted with/against each party majority
- if norm in ("Yea","Nay"):
- r_pos, d_pos = cls["r_pos"], cls["d_pos"]
- if r_pos != "Split":
- if norm == r_pos: vw_gop += 1
- else: va_gop += 1
- if d_pos != "Split":
- if norm == d_pos: vw_dem += 1
- else: va_dem += 1
- # lone wolf vs own party
- own_pos = r_pos if member_party == "R" else d_pos if member_party == "D" else None
- if own_pos and own_pos != "Split" and norm != own_pos:
- own_totals = rec["totals"]["R"] if member_party == "R" else rec["totals"]["D"]
- defectors = own_totals["nay"] if own_pos == "Yea" else own_totals["yea"]
- if defectors <= threshold:
- lone_wolf += 1
- # row for the table
- rows.append({
- "y": rec.get("year") or "",
- "r": rec.get("num") or 0,
- "d": rec.get("date_raw") or rec.get("date") or "",
- "ln": rec.get("bill") or "",
- "q": rec.get("question") or "",
- "ds": (rec.get("desc") or "")[:90],
- "rs": rec.get("result") or "",
- "m": raw or "",
- "ry": rec["totals"]["R"]["yea"], "rn": rec["totals"]["R"]["nay"],
- "dy": rec["totals"]["D"]["yea"], "dn": rec["totals"]["D"]["nay"],
- "a": cls["alignment"],
- "b": cls["blocked"] or "",
- })
- months_sorted = sorted(month_align.keys())
- align_labels = ["Helped Republicans","Helped Democrats","Helped Both","Helped Neither"]
- monthly = {lab: [month_align[m].get(lab, 0) for m in months_sorted]
- for lab in align_labels}
- total = len(rows)
- return {
- "chamber": chamber,
- "total": total, "voting": voting,
- "yeas": yeas, "nays": nays, "nv": nv, "present": present,
- "alignment": dict(align_counts),
- "member": dict(raw_vote_counts),
- "blocked": dict(blocked_counts),
- "months": months_sorted, "monthly": monthly,
- "rows": rows,
- "blocked_dem_count": blocked_counts.get("Democrat", 0),
- "blocked_rep_count": blocked_counts.get("Republican", 0),
- "lone_wolf": lone_wolf,
- "voted_with_gop": vw_gop, "voted_against_gop": va_gop,
- "voted_with_dem": vw_dem, "voted_against_dem": va_dem,
- "lone_wolf_threshold": threshold,
- }
|